diff --git a/.env b/.env index 40b1e9c..7a9fbac 100644 --- a/.env +++ b/.env @@ -40,3 +40,13 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 ###> symfony/mailer ### MAILER_DSN=null://null ###< symfony/mailer ### + +###> symfony/mercure-bundle ### +# See https://symfony.com/doc/current/mercure.html#configuration +# The URL of the Mercure hub, used by the app to publish updates (can be a local URL) +MERCURE_URL=http://mercure/.well-known/mercure +# The public URL of the Mercure hub, used by the browser to connect +MERCURE_PUBLIC_URL=http://localhost:3000/.well-known/mercure +# The secret used to sign the JWTs +MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" +###< symfony/mercure-bundle ### diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..4b05943 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,92 @@ +name: Code Quality + +on: + push: + branches: [ main, develop, chore/*, feature/*, fix/* ] + pull_request: + branches: [ main, develop ] + +jobs: + quality-checks: + name: Quality Checks (PHP 8.4) + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring, xml, ctype, iconv, intl, pdo_sqlite, dom, filter, json + coverage: none + tools: composer:v2 + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer dependencies + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run PHPStan (Level Max) + run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=512M + + - name: Run PHP-CS-Fixer (Check only) + run: vendor/bin/php-cs-fixer fix --dry-run --diff --verbose + + - name: Run PHPUnit Tests + run: vendor/bin/phpunit --testdox + + - name: Run PHPMD + run: vendor/bin/phpmd src github rulesets.xml + continue-on-error: true + + - name: Run PHPArkitect (Architecture validation) + run: vendor/bin/phparkitect check + + phpunit-coverage: + name: PHPUnit with Coverage + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring, xml, ctype, iconv, intl, pdo_sqlite, dom, filter, json + coverage: xdebug + tools: composer:v2 + + - name: Cache Composer dependencies + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run PHPUnit with Coverage + run: vendor/bin/phpunit --coverage-text --coverage-clover=coverage.xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..be37840 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# Quick Reference for Coding Agents + +## Commands +```bash +# Database & Fixtures +make db.reset # Drop, create, migrate database (empty) +make db.refresh # Reset database + load YAML fixtures +make fixtures # Load benchmarks from fixtures/benchmarks/*.yaml + +# Tests +docker-compose run --rm main vendor/bin/phpunit # All tests +docker-compose run --rm main vendor/bin/phpunit tests/Path/To/SpecificTest.php # Single test + +# Code Quality +make phpcsfixer-fix # Fix code style (PSR-12) +make phpstan # Static analysis (level 9) +make quality # Run all checks + fixes + +# Assets (CSS/JS) +make assets.refresh # Force refresh assets: compile, regenerate hashes, clear cache, restart + # Use when CSS/JS changes don't appear in browser + # Don't forget to hard refresh browser (Ctrl+Shift+R) + +# Benchmarks +make run test=Loop iterations=10 # Run specific benchmark +docker-compose run --rm main php bin/console benchmark:run --test=Loop --php-version=php84 + +# Mercure (Real-Time) +./scripts/mercure-verify.sh # Verify Mercure setup +./scripts/mercure-listen.sh # Watch real-time events +./scripts/mercure-test.sh 5 Loop php84 # End-to-end test +``` + +## Code Style +- **PHP 8.4+** with `declare(strict_types=1)` at top of every file +- **PSR-12** + Symfony style enforced by PHP-CS-Fixer +- **Imports**: Fully qualified, alphabetically sorted (classes, functions, constants) +- **Arrays**: Short syntax `[]`, trailing commas in multiline +- **Strings**: Concatenation with ` . ` (spaces around dot) +- **Comparison**: Strict (`===`, `!==`) always, Yoda style (`null === $var`) +- **Classes**: `final readonly` by default, ordered elements (constants → properties → constructor → methods) +- **Types**: Full type hints everywhere (params, returns, properties with asymmetric visibility `public private(set)`) +- **PHPDoc**: Only for interfaces, complex return types (`@return Type[]`), or "why" explanations + +## Architecture (Clean + DDD + Hexagonal) +- **Namespace**: `Jblairy\PhpBenchmark\{Domain|Application|Infrastructure}\...` +- **Dependencies flow INWARD**: Infrastructure → Application → Domain +- **Domain**: Pure PHP, no framework (Symfony/Doctrine), defines Ports (interfaces) +- **Infrastructure**: Implements Adapters for Domain Ports, uses any library +- **Naming**: Ports end with `Port`, Value Objects are nouns, Services are verbs + +See [docs/architecture/01-overview.md](docs/architecture/01-overview.md) and [CLAUDE.md](CLAUDE.md) for details. diff --git a/CLAUDE.md b/CLAUDE.md index 2545ca2..80f0404 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,9 +37,6 @@ This includes: │ ├── 03-ports-adapters.md │ └── 04-execution-flow.md ├── concepts/ - │ ├── clean-architecture.md - │ ├── ddd-patterns.md - │ └── value-objects-vs-entities.md └── guides/ ├── creating-benchmarks.md ├── testing.md @@ -54,6 +51,29 @@ make up # Start all Docker containers make start # Build Docker images ``` +### Database & Fixtures +```bash +# Database management +make db.reset # Drop, create, and migrate database (empty) +make db.refresh # Reset database and load fixtures +make fixtures # Load benchmark fixtures from YAML files + +# Manual commands +docker-compose run --rm main php bin/console doctrine:database:drop --force --if-exists +docker-compose run --rm main php bin/console doctrine:database:create +docker-compose run --rm main php bin/console doctrine:migrations:migrate --no-interaction +docker-compose run --rm main php bin/console doctrine:fixtures:load --no-interaction +``` + +### Mercure Real-Time (Debugging & Testing) +```bash +./scripts/mercure-verify.sh # Verify Mercure configuration +./scripts/mercure-listen.sh # Listen to real-time events (pretty format) +./scripts/mercure-listen.sh "" json # Listen with JSON output +./scripts/mercure-listen.sh "" stats # Show event statistics +./scripts/mercure-test.sh 5 Loop php84 # End-to-end test (5 iterations) +``` + ### Running Benchmarks ```bash # Run all benchmarks across all PHP versions @@ -80,210 +100,79 @@ make quality # Run all quality checks and fixes ### Testing ```bash -docker-compose run --rm main vendor/bin/phpunit -``` - -## Architecture - -This project follows **Clean Architecture** with **Domain-Driven Design (DDD)** and **Hexagonal Architecture (Ports & Adapters)** patterns. - -### 🎯 Core Principles - -1. **Clean Architecture**: Dependencies point inward (Infrastructure → Application → Domain) -2. **DDD**: Business logic is in the Domain layer, isolated from technical details -3. **Hexagonal**: Domain defines Ports (interfaces), Infrastructure provides Adapters (implementations) - -### 📁 Project Structure (Clean Architecture Layers) - -``` -src/ -├── Application/ # Use Cases (orchestration) -│ ├── Service/ -│ │ └── ChartBuilder.php -│ └── UseCase/ -│ ├── AsyncBenchmarkRunner.php -│ └── BenchmarkOrchestrator.php -│ -├── Domain/ # Business Logic (core) -│ ├── Benchmark/ -│ │ ├── Contract/ # Abstractions -│ │ │ ├── AbstractBenchmark.php -│ │ │ └── Benchmark.php (interface) -│ │ ├── Exception/ # Domain exceptions -│ │ ├── Model/ # Value Objects & Domain Models -│ │ │ ├── BenchmarkConfiguration.php -│ │ │ ├── BenchmarkResult.php -│ │ │ └── ExecutionContext.php -│ │ ├── Port/ # Interfaces (Hexagonal Ports) -│ │ │ ├── BenchmarkExecutorPort.php -│ │ │ ├── BenchmarkRepositoryPort.php -│ │ │ ├── CodeExtractorPort.php -│ │ │ ├── ResultPersisterPort.php -│ │ │ └── ScriptExecutorPort.php -│ │ ├── Service/ # Domain Services -│ │ │ └── SingleBenchmarkExecutor.php -│ │ └── Test/ # Benchmark implementations -│ │ ├── Loop.php -│ │ ├── ArrayMap/ -│ │ ├── StringConcatenation/ -│ │ └── ... (40+ benchmarks) -│ └── PhpVersion/ -│ ├── Attribute/ # PHP version targeting (#[Php84], #[All]) -│ └── Enum/ -│ └── PhpVersion.php -│ -└── Infrastructure/ # Technical implementations (adapters) - ├── Cli/ - │ └── BenchmarkCommand.php - ├── Execution/ - │ ├── CodeExtraction/ - │ │ └── ReflectionCodeExtractor.php - │ ├── Docker/ - │ │ └── DockerScriptExecutor.php - │ └── ScriptBuilding/ - │ └── InstrumentedScriptBuilder.php - ├── Persistence/ - │ ├── Doctrine/ - │ │ ├── Entity/ # Doctrine entities - │ │ │ └── Pulse.php - │ │ ├── Repository/ # Doctrine repositories - │ │ │ └── PulseRepository.php - │ │ └── DoctrinePulseResultPersister.php - │ └── InMemory/ - │ └── InMemoryBenchmarkRepository.php - └── Web/ - └── Controller/ - └── DashboardController.php -``` - -### 🔄 Dependency Flow (Hexagonal Architecture) - -**Port (Domain) → Adapter (Infrastructure)** +# Unit tests +make test # Run all tests +docker-compose run --rm main vendor/bin/phpunit tests/Path/To/SpecificTest.php # Single test -| Port (Interface in Domain) | Adapter (Implementation in Infrastructure) | -|----------------------------|---------------------------------------------| -| `CodeExtractorPort` | `ReflectionCodeExtractor` | -| `BenchmarkRepositoryPort` | `InMemoryBenchmarkRepository` | -| `ScriptExecutorPort` | `DockerScriptExecutor` | -| `ResultPersisterPort` | `DoctrinePulseResultPersister` | -| `BenchmarkExecutorPort` | `SingleBenchmarkExecutor` (Domain Service) | +# Mutation testing (tests the quality of your tests) +make infection-report # Run Infection (no threshold, for exploration) +make infection # Run Infection with strict thresholds (MSI >= 80%) -**Configuration in `config/services.yaml`:** -```yaml -Jblairy\PhpBenchmark\Domain\Benchmark\Port\CodeExtractorPort: - class: Jblairy\PhpBenchmark\Infrastructure\Execution\CodeExtraction\ReflectionCodeExtractor +# Manual mutation testing +docker-compose run --rm main phpdbg -qrr vendor/bin/infection --threads=4 --show-mutations ``` -### 🚀 Execution Flow - -``` -1. CLI Command (Infrastructure/Cli/BenchmarkCommand) - ↓ Receives: php bin/console benchmark:run --test=Loop - ↓ Parses options and calls Application layer - -2. Use Case (Application/UseCase/BenchmarkOrchestrator) - ↓ Orchestrates execution - ↓ Creates BenchmarkConfiguration (Domain Model) - ↓ Delegates to AsyncBenchmarkRunner - -3. AsyncBenchmarkRunner (Application/UseCase) - ↓ Uses BenchmarkExecutorPort (Domain Port) - ↓ Runs benchmarks in parallel (Spatie\Async\Pool) - -4. SingleBenchmarkExecutor (Domain/Benchmark/Service) - ↓ Implements BenchmarkExecutorPort - ↓ Uses CodeExtractorPort to extract code - ↓ Uses ScriptExecutorPort to execute - ↓ Returns BenchmarkResult (Value Object) - -5. DockerScriptExecutor (Infrastructure/Execution/Docker) - ↓ Implements ScriptExecutorPort - ↓ Executes in Docker container via docker-compose exec - ↓ Returns execution metrics - -6. DoctrinePulseResultPersister (Infrastructure/Persistence/Doctrine) - ↓ Implements ResultPersisterPort - ↓ Converts BenchmarkResult (Domain) → Pulse (Doctrine Entity) - ↓ Persists to MariaDB via Doctrine ORM -``` +**Note**: Infection requires a code coverage driver (phpdbg or PCOV). The `make` commands use phpdbg which is already available in the container. -### 🏗️ Domain-Driven Design (DDD) Concepts +See [docs/guides/mutation-testing.md](docs/guides/mutation-testing.md) for complete Infection guide. -**Value Objects (Domain/Benchmark/Model/):** -- `BenchmarkConfiguration`: Immutable configuration (benchmark + PHP version + iterations) -- `BenchmarkResult`: Immutable result (execution time + memory usage) -- `ExecutionContext`: Immutable execution context - -**Entities (Infrastructure/Persistence/Doctrine/Entity/):** -- `Pulse`: Doctrine entity with ID, persisted to database +## Architecture -**Domain Services (Domain/Benchmark/Service/):** -- `SingleBenchmarkExecutor`: Coordinates benchmark execution +This project follows **Clean Architecture + DDD + Hexagonal Architecture** (Ports & Adapters). -**Ports (Domain/Benchmark/Port/):** -- Interfaces that define contracts for Infrastructure +📖 **Full documentation**: [docs/architecture/01-overview.md](docs/architecture/01-overview.md) -**Adapters (Infrastructure/):** -- Concrete implementations of Ports +## Database & Fixtures -### 📝 Creating Benchmarks +The project uses MariaDB 10.11 via Docker with Doctrine ORM for entity management. -Benchmarks live in `src/Domain/Benchmark/Test/` and must: +### Database Structure -1. **Extend `AbstractBenchmark`** (implements `Benchmark` interface) -2. **Use PHP version attributes** to specify compatibility: - - `#[All]` - Run on all PHP versions - - `#[Php73]`, `#[Php74]`, `#[Php84]`, etc. - Run on specific versions - - Multiple attributes can be used on different methods +**Entities:** +- **Benchmark** (`benchmarks` table) - Stores benchmark code and metadata loaded from YAML fixtures + - Fields: slug, name, category, description, code, phpVersions, tags, icon + - Loaded from `fixtures/benchmarks/*.yaml` files + +- **Pulse** (`pulse` table) - Stores benchmark execution results + - Fields: benchId, name, phpVersion, executionTimeMs, memoryUsedBytes, memoryPeakByte + - Created during benchmark execution -**Example:** -```php -namespace Jblairy\PhpBenchmark\Domain\Benchmark\Test; +### Fixtures System -use Jblairy\PhpBenchmark\Domain\Benchmark\Contract\AbstractBenchmark; -use Jblairy\PhpBenchmark\Domain\PhpVersion\Attribute\All; +Benchmarks are now defined as YAML files in `fixtures/benchmarks/` and loaded into the database: -final class Loop extends AbstractBenchmark -{ - #[All] - public function execute(): void - { - $x = []; - for ($i = 0; 100000 > $i; ++$i) { - $x[] = $i * 2; - } - } -} +**Example fixture** (`fixtures/benchmarks/array-fill-benchmark.yaml`): +```yaml +slug: array-fill +name: 'Array Fill' +category: 'Array Operations' +description: 'Fill array using array_fill() function' +icon: 📦 +tags: + - array + - fill +phpVersions: + - php56 + - php70 + - php84 + - php85 +code: | + for ($i = 0; $i < 10000; $i++) { + $array = array_fill(0, 100, 'value'); + } ``` -### 🐳 PHP Version System - -- **PhpVersion Enum** (`src/Domain/PhpVersion/Enum/PhpVersion.php`) - Defines available PHP versions -- **Version Attributes** (`src/Domain/PhpVersion/Attribute/`) - PHP 5.6 through 8.5, plus `All.php` -- **Docker Services** (`docker-compose.yml`) - Each PHP version runs as isolated container: - - Shared volume mount at `/srv/php_benchmark` - - 512MB memory limit - - 1 CPU limit - - `tail -f /dev/null` to keep running - -### 📊 Data Layer - -**Domain Models (Value Objects):** -- `BenchmarkResult` - Immutable result object - -**Infrastructure Entities:** -- `Pulse` (src/Infrastructure/Persistence/Doctrine/Entity/Pulse.php) - Doctrine entity for database - - Fields: `id`, `benchId`, `name`, `phpVersion`, `executionTimeMs`, `memoryUsedBytes`, `memoryPeakByte` - -**Dashboard:** -- `DashboardController` (src/Infrastructure/Web/Controller/DashboardController.php) - Web UI at `/dashboard` - - Aggregates benchmark results by test and PHP version - - Calculates percentiles (P50, P80, P90, P95, P99) and averages - - Generates charts via `ChartBuilder` +**Loading fixtures:** +```bash +make fixtures # Load all YAML files from fixtures/benchmarks/ +make db.refresh # Reset database + load fixtures +``` -## Database +### Migrations -The project uses MariaDB 10.11 via Docker. Doctrine ORM is configured for entity management and migrations are in `migrations/`. +Doctrine migrations are in `migrations/`. Current migrations: +- `Version20251103175019` - Creates `benchmarks` table +- Earlier versions - Create `pulse` table and related schema ## Code Standards @@ -301,6 +190,107 @@ The project uses MariaDB 10.11 via Docker. Doctrine ORM is configured for entity - **Docker** for PHP version isolation - **MariaDB 10.11** database +### Code Comments and Documentation + +**Principle: Self-documenting code over comments** + +#### ✅ DO: Write expressive, well-named code + +```php +// ✅ GOOD - Self-explanatory +final readonly class StatisticsCalculator +{ + public function calculatePercentile(array $sortedValues, int $percentile): float + { + $index = (int) ceil(($percentile / 100) * count($sortedValues)) - 1; + return $sortedValues[max(0, $index)]; + } +} +``` + +#### ❌ DON'T: Use comments to explain what code does + +```php +// ❌ BAD - Comment explains poor naming +// Calculate the p value from the arr +public function calc(array $arr, int $p): float +{ + // Get the index + $i = (int) ceil(($p / 100) * count($arr)) - 1; + // Return the value + return $arr[max(0, $i)]; +} +``` + +#### ✅ WHEN to use comments + +Comments are **acceptable and encouraged** for: + +1. **"Why" not "What"** - Explain business decisions or complex algorithms + ```php + // Using P90 instead of average to avoid outliers skewing benchmark results + $p90 = $this->calculatePercentile($times, 90); + ``` + +2. **API documentation** - Public interfaces and contracts (PHPDoc) + ```php + /** + * Port for accessing dashboard data + */ + interface DashboardRepositoryPort + { + /** + * @return BenchmarkMetrics[] Grouped by benchmark ID and PHP version + */ + public function getAllBenchmarkMetrics(): array; + } + ``` + +3. **Class-level documentation** - Purpose and responsibility + ```php + /** + * Doctrine adapter implementing DashboardRepositoryPort + * + * Follows Dependency Inversion Principle: implements interface from Domain + */ + final readonly class DoctrineDashboardRepository implements DashboardRepositoryPort + ``` + +4. **Non-obvious workarounds** - Technical constraints or edge cases + ```php + // PHP 5.6 doesn't support ** operator, must use pow() + if ($phpVersion === 'php56') { + return pow($base, $exponent); + } + ``` + +#### ❌ AVOID these comments + +- **Obvious comments**: `// Set the name` above `$this->name = $name;` +- **Commented-out code**: Delete it (use git history if needed) +- **Redundant documentation**: Repeating method signature in PHPDoc + ```php + // ❌ BAD + /** + * Get dashboard data + * @return DashboardData The dashboard data + */ + public function getDashboardData(): DashboardData + ``` + +- **TODO/FIXME without context**: Always add reason and date + ```php + // ❌ BAD: TODO fix this + // ✅ GOOD: TODO (2024-10-15): Refactor to use async for performance + ``` + +#### Best Practices + +1. **Prefer refactoring over commenting** - If code needs a comment to be understood, consider refactoring +2. **Use meaningful names** - `calculatePercentile()` > `calc()` + comment +3. **Extract complex logic** - Into well-named private methods or Value Objects +4. **Keep comments up-to-date** - Outdated comments are worse than no comments + ### Quality Commands ```bash @@ -367,7 +357,6 @@ Jblairy\PhpBenchmark\ - [Ports & Adapters](docs/architecture/03-ports-adapters.md) - Hexagonal architecture **Concepts:** -- [Value Objects vs Entities](docs/concepts/value-objects-vs-entities.md) - DDD patterns explained **Guides:** - [Creating Benchmarks](docs/guides/creating-benchmarks.md) - Step-by-step tutorial diff --git a/Dockerfile.php85 b/Dockerfile.php85 index be7b175..4010900 100644 --- a/Dockerfile.php85 +++ b/Dockerfile.php85 @@ -1,6 +1,6 @@ FROM debian:bullseye -# 📦 Installer les dépendances nécessaires +# Install required dependencies RUN apt update && apt install -y \ build-essential \ libxml2-dev \ @@ -20,15 +20,15 @@ RUN apt update && apt install -y \ bzip2 \ git -# 📥 Télécharger et extraire PHP 8.5.0 Alpha 1 +# Download and extract PHP 8.5.0 WORKDIR /usr/src -RUN wget https://downloads.php.net/~daniels/php-8.5.0alpha1.tar.xz && \ - tar -xf php-8.5.0alpha1.tar.xz && \ - rm php-8.5.0alpha1.tar.xz +RUN wget https://downloads.php.net/~daniels/php-8.5.0RC3.tar.xz && \ + tar -xf php-8.5.0RC3.tar.xz && \ + rm php-8.5.0RC3.tar.xz -WORKDIR /usr/src/php-8.5.0alpha1 +WORKDIR /usr/src/php-8.5.0RC3 -# ⚙️ Configurer, compiler et installer +# Configure, compile and install RUN ./configure --prefix=/usr/local/php8.5 \ --with-openssl \ --enable-mbstring \ @@ -37,8 +37,8 @@ RUN ./configure --prefix=/usr/local/php8.5 \ make -j$(nproc) && \ make install -# 🧪 Ajouter PHP au PATH +# Add PHP to PATH ENV PATH="/usr/local/php8.5/bin:${PATH}" -# 🔍 Vérification +# Verification CMD ["php", "-v"] diff --git a/Makefile b/Makefile index 889b35e..58312b3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: up start run phpcsfixer phpcsfixer-fix phpstan quality phpmd phparkitect +.PHONY: up start run fixtures db.reset db.refresh phpcsfixer phpcsfixer-fix phpstan quality phpmd phparkitect infection assets.refresh up: docker-compose up -d --remove-orphans @@ -22,6 +22,26 @@ run: docker-compose run --rm main php bin/console benchmark:run --test=$(test) --iterations=$(or $(iterations),1) --php-version=$(version); \ fi +# Load fixtures into database from YAML files +fixtures: + @echo "🔄 Loading fixtures from fixtures/benchmarks/*.yaml..." + @docker-compose exec main php bin/console doctrine:fixtures:load --no-interaction + @echo "✅ Fixtures loaded successfully" + +# Reset database (drop, create, migrate) - without fixtures +db.reset: + @echo "🗑️ Dropping database..." + @docker-compose run --rm main php bin/console d:d:d --force --if-exists + @echo "📦 Creating database..." + @docker-compose run --rm main php bin/console d:d:c + @echo "🔄 Running migrations..." + @docker-compose run --rm main php bin/console d:m:m --no-interaction + @echo "✅ Database reset complete" + +# Reset database and load fixtures (full refresh) +db.refresh: db.reset fixtures + @echo "✅ Database refreshed with fixtures" + phpcsfixer: docker-compose run --rm main vendor/bin/php-cs-fixer fix --dry-run --diff @@ -29,7 +49,7 @@ phpcsfixer-fix: docker-compose run --rm main vendor/bin/php-cs-fixer fix phpstan: - docker-compose run --rm main vendor/bin/phpstan analyse + docker-compose run --rm main vendor/bin/phpstan analyse --memory-limit=512M phpmd: docker-compose run --rm main vendor/bin/phpmd ./src ansi rulesets.xml @@ -37,4 +57,48 @@ phpmd: phparkitect: docker-compose run --rm main vendor/bin/phparkitect check +rector: + docker-compose run --rm main vendor/bin/rector + +test: + docker-compose run --rm main vendor/bin/phpunit + +test-coverage: + @echo "📊 Generating code coverage..." + docker-compose run --rm main phpdbg -qrr vendor/bin/phpunit --coverage-xml=var/coverage/coverage-xml --log-junit=var/coverage/junit.xml + +infection: + @echo "🧬 Running Infection mutation testing..." + @echo "⚠️ This may take several minutes..." + @echo "📊 Step 1/2: Generating code coverage with PHPUnit..." + @docker-compose run --rm main phpdbg -qrr vendor/bin/phpunit --coverage-xml=var/coverage/coverage-xml --log-junit=var/coverage/junit.xml + @echo "🧬 Step 2/2: Running mutations..." + docker-compose run --rm main vendor/bin/infection --coverage=var/coverage --threads=4 --show-mutations --min-msi=80 --min-covered-msi=85 + +infection-report: + @echo "🧬 Running Infection mutation testing (report only, no MSI threshold)..." + @echo "📊 Step 1/2: Generating code coverage with PHPUnit..." + @docker-compose run --rm main phpdbg -qrr vendor/bin/phpunit --coverage-xml=var/coverage/coverage-xml --log-junit=var/coverage/junit.xml + @echo "🧬 Step 2/2: Running mutations..." + docker-compose run --rm main vendor/bin/infection --coverage=var/coverage --threads=4 --show-mutations + quality: phpcsfixer-fix phpstan phpmd phparkitect + +# Force refresh assets (CSS/JS) and invalidate browser cache +# Useful when CSS/JS changes are not reflected in the browser +# This command: +# 1. Compiles all assets (SCSS → CSS, etc.) +# 2. Deletes compiled assets to force new hash generation +# 3. Clears Symfony cache +# 4. Restarts the main container for a clean state +assets.refresh: + @echo "🎨 Compiling assets..." + @docker-compose exec main php bin/console asset-map:compile + @echo "🗑️ Removing compiled assets to force hash regeneration..." + @rm -rf public/assets + @echo "🔄 Clearing Symfony cache..." + @docker-compose exec main php bin/console cache:clear + @echo "🔄 Restarting main container..." + @docker-compose restart main + @echo "✅ Assets refreshed! New CSS/JS hashes generated." + @echo "💡 Hard refresh your browser (Ctrl+Shift+R or Cmd+Shift+R) to see changes." diff --git a/README.md b/README.md index 9399bd2..2073dc0 100644 --- a/README.md +++ b/README.md @@ -1,170 +1,458 @@ -# PHP Benchmark Suite - -A modern, benchmarking framework for PHP that allows testing performance of different implementations and evaluating performance evolution across PHP versions (5.6 through 8.5). - -## Features - -- **Performance Testing**: 40+ automated benchmarks covering arrays, strings, loops, OOP, and more -- **Version Comparison**: Test performance across different PHP versions (5.6 to 8.5) -- **Parallel Execution**: Concurrent benchmark execution using Spatie\Async (100 parallel tasks) -- **Docker Isolation**: Each PHP version runs in isolated Docker containers -- **Web Dashboard**: Visual charts and statistics at `/dashboard` -- **Clean Architecture**: Domain-driven design with hexagonal ports & adapters -- **Modular Architecture**: Easily add your own test cases - -## Requirements - -- docker -- docker-compose - -## Installation +
+ +# ⚡ PHP Benchmark Suite + +Code Quality +PHP Version +PHPStan Level +License + +**A modern benchmarking framework for PHP testing performance across versions 5.6 to 8.5** + +[Features](#-features) • [Installation](#-installation) • [Usage](#-usage) • [Architecture](#-architecture) • [Contributing](#-contributing) + +
+ +--- + +## ✨ Features + + + + + + + + + + + + + + +
+

🎯 Performance Testing

+
    +
  • 107+ Benchmarks covering arrays, strings, loops, OOP, functions, and more
  • +
  • Statistical Analysis with percentiles (p50, p80, p90, p95, p99)
  • +
  • Memory Profiling tracks memory usage and peaks
  • +
+
+

🔄 Version Comparison

+
    +
  • Multi-Version Testing from PHP 5.6 to 8.5
  • +
  • Visual Charts compare performance across versions
  • +
  • Evolution Tracking see how PHP improves over time
  • +
+
+

⚡ High Performance

+
    +
  • Parallel Execution using Spatie\Async (100 concurrent tasks)
  • +
  • Docker Isolation each version in isolated containers
  • +
  • Real-Time Updates via Mercure (Server-Sent Events)
  • +
+
+

🏗️ Modern Architecture

+
    +
  • Clean Architecture with DDD + Hexagonal design
  • +
  • SOLID Principles validated by PHPStan level max
  • +
  • Architecture Tests enforced by PHPArkitect
  • +
+
+

📊 Web Dashboard

+
    +
  • Interactive Charts powered by Chart.js
  • +
  • Live Progress watch benchmarks execute in real-time
  • +
  • Detailed Stats view execution times, memory, percentiles
  • +
+
+

🎨 Easy Customization

+
    +
  • YAML Fixtures define benchmarks in simple YAML files
  • +
  • Database Storage benchmarks persisted in MariaDB
  • +
  • Hot Reload add new benchmarks without code changes
  • +
+
+ +## 📋 Requirements + +| Requirement | Version | Purpose | +|------------|---------|---------| +| Docker | Latest | Container runtime | +| Docker Compose | v2+ | Multi-container orchestration | +| Make | Any | Task automation (optional) | + +## 🚀 Installation ```bash git clone https://github.com/jblairy/php-benchmark.git cd php-benchmark -make up +make up # Start Docker containers +make db.refresh # Create database and load benchmark fixtures ``` -## Usage +The `make db.refresh` command will: +1. Create the MariaDB database +2. Run migrations to create tables +3. Load 100+ benchmark definitions from YAML fixtures + +## 💻 Usage + +### Quick Start -### Run All Benchmarks ```bash +# Run all benchmarks make run + +# View results in browser +open http://localhost/dashboard ``` -### Run Specific Benchmark +### Advanced Usage + +
+Run Specific Benchmark + ```bash # Run a specific test make run test=Loop -# Run with specific iterations +# Run with custom iterations make run test=Loop iterations=100 # Run on specific PHP version -docker-compose run --rm main php bin/console benchmark:run --test=Loop --php-version=php84 --iterations=10 +docker-compose run --rm main php bin/console benchmark:run \ + --test=Loop \ + --php-version=php84 \ + --iterations=10 ``` +
-### View Results -Open your browser at `http://localhost/dashboard` to see charts and statistics. +
+Development Commands + +```bash +# Database management +make db.reset # Drop and recreate database (empty) +make db.refresh # Reset database + load fixtures +make fixtures # Load benchmark fixtures only + +# Testing +make test # Run all PHPUnit tests (50 tests, 212 assertions) +make test-coverage # Run tests with coverage report + +# Code Quality +make phpstan # Static analysis (level max) +make phpcsfixer # Check code style (PSR-12) +make phpcsfixer-fix # Fix code style automatically +make quality # Run all quality checks + +# Assets (CSS/JS) +make assets.refresh # Rebuild and refresh frontend assets +``` +
-## Architecture +
+Mercure Real-Time Testing -This project implements **Clean Architecture + DDD + Hexagonal Architecture**. +```bash +# Verify Mercure setup +./scripts/mercure-verify.sh +# Watch real-time events +./scripts/mercure-listen.sh + +# End-to-end test (5 iterations, Loop benchmark, PHP 8.4) +./scripts/mercure-test.sh 5 Loop php84 ``` -src/ -├── Application/ # Use Cases (orchestration) -├── Domain/ # Business Logic (pure PHP, no framework) -│ └── Benchmark/ -│ ├── Model/ # Value Objects (immutable) -│ ├── Port/ # Interfaces (Hexagonal Ports) -│ ├── Service/ # Domain Services -│ └── Test/ # 40+ benchmark implementations -└── Infrastructure/ # Technical implementations (adapters) - ├── Cli/ # Symfony Console commands - ├── Execution/ # Docker, code extraction - ├── Persistence/ # Doctrine ORM, repositories - └── Web/ # Dashboard controllers -``` +
-**Key Principle:** Dependencies point inward → Infrastructure → Application → Domain +### 📊 View Results -### Documentation +Access the web dashboard to explore benchmark results: +- **URL**: `http://localhost/dashboard` +- **Features**: Interactive charts, live progress tracking, detailed statistics -- **[docs/README.md](docs/README.md)** - Complete documentation index -- **[docs/architecture/01-overview.md](docs/architecture/01-overview.md)** - Architecture deep dive -- **[CLAUDE.md](CLAUDE.md)** - Developer reference guide +## 🏗️ Architecture -## Contributing +This project follows **Clean Architecture** principles with **Domain-Driven Design (DDD)** and **Hexagonal Architecture** (Ports & Adapters). -Contributions are welcome! Here's how to contribute: +``` +┌─────────────────────────────────────────────────────────┐ +│ Infrastructure │ +│ (Symfony, Doctrine, Docker, Web, CLI, Persistence) │ +│ ↓ │ +│ Application │ +│ (Use Cases, Orchestration) │ +│ ↓ │ +│ Domain │ +│ (Business Logic - Pure PHP) │ +└─────────────────────────────────────────────────────────┘ +``` + +### Directory Structure -### Contribution Guidelines -- **Write all documentation in English** (code, comments, docs, commits, PRs) -- Follow **PSR-12** coding standards (enforced by PHP-CS-Fixer) -- Follow **PHPStan level 9** rules -- Respect **Clean Architecture** principles (validated by PHPArkitect) -- Document your benchmarks -- Write tests for new features - -See **[CLAUDE.md](CLAUDE.md)** for detailed developer guidelines. - -### Creating Custom Benchmarks - -1. **Create a class in `src/Domain/Benchmark/Test/`** -2. **Extend `AbstractBenchmark`** -3. **Add PHP version attributes** -4. **Implement your test method** - -**Example:** - -```php - 'success', - false => 'failure', - }; - } -} +``` +src/ +├── Domain/ # 🎯 Core Business Logic (Framework-free) +│ ├── Benchmark/ # Benchmark models, value objects, ports +│ ├── Dashboard/ # Dashboard domain models, statistics +│ └── PhpVersion/ # PHP version enumeration +│ +├── Application/ # 📦 Use Cases (Orchestration Layer) +│ ├── UseCase/ # Benchmark execution orchestration +│ └── Dashboard/ # Dashboard data aggregation +│ +└── Infrastructure/ # 🔧 Technical Implementation + ├── Async/ # Async execution (Spatie\Async) + ├── Cli/ # Symfony Console commands + ├── Execution/ # Docker script execution + ├── Mercure/ # Real-time SSE events + ├── Persistence/ # Doctrine entities, repositories, fixtures + └── Web/ # Symfony controllers, components, Twig ``` -**Available Attributes:** -- `#[All]` - Run on all PHP versions -- `#[Php56]`, `#[Php70]`, `#[Php71]`, `#[Php72]`, `#[Php73]`, `#[Php74]` - Legacy versions -- `#[Php80]`, `#[Php81]`, `#[Php82]`, `#[Php83]`, `#[Php84]`, `#[Php85]` - Modern versions +### Key Principles -### Code Quality Tools +- ✅ **Dependencies flow inward**: Infrastructure → Application → Domain +- ✅ **Domain is framework-agnostic**: Pure PHP, no Symfony/Doctrine dependencies +- ✅ **Ports & Adapters**: Domain defines interfaces (Ports), Infrastructure implements them (Adapters) +- ✅ **Validated by PHPArkitect**: Architecture rules are enforced automatically -```bash -# Check code style -make phpcsfixer +### 📚 Documentation -# Fix code style -make phpcsfixer-fix +| Document | Description | +|----------|-------------| +| [docs/architecture/01-overview.md](docs/architecture/01-overview.md) | Architecture deep dive | +| [docs/architecture/02-layers.md](docs/architecture/02-layers.md) | Layer responsibilities | +| [docs/architecture/03-ports-adapters.md](docs/architecture/03-ports-adapters.md) | Hexagonal architecture details | +| [CLAUDE.md](CLAUDE.md) | Developer reference guide | +| [docs/README.md](docs/README.md) | Complete documentation index | -# Run static analysis -make phpstan +## 🤝 Contributing -# Run architecture validation -docker-compose run --rm main vendor/bin/phparkitect check +We love contributions! Whether it's bug fixes, new benchmarks, or documentation improvements, all contributions are welcome. -# Run all quality checks -make quality -``` +### Contribution Guidelines + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
📝Code StyleFollow PSR-12 standards (enforced by PHP-CS-Fixer)
🔍Static AnalysisPass PHPStan level max (strictest level)
🏗️ArchitectureRespect Clean Architecture principles (validated by PHPArkitect)
TestingWrite PHPUnit tests for new features
🌐LanguageWrite all code, comments, docs, commits, PRs in English
📚DocumentationDocument your benchmarks and architectural decisions
+ +### Development Workflow + +1. **Fork** the repository +2. **Create** a feature branch (`git checkout -b feature/amazing-feature`) +3. **Make** your changes +4. **Run quality checks**: `make quality` +5. **Run tests**: `make test` +6. **Commit** your changes (see [Atomic Commits Guide](docs/guides/atomic-commits.md)) +7. **Push** to your branch (`git push origin feature/amazing-feature`) +8. **Open** a Pull Request + +### Quick Quality Check -## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. +```bash +# Run all quality checks before committing +make quality -## Acknowledgments -- PHP Community for inspiration -- Project contributors -- Everyone who tests and reports issues +# This runs: +# ✓ PHPStan (static analysis) +# ✓ PHP-CS-Fixer (code style) +# ✓ PHPUnit (tests) +# ✓ PHPMD (mess detection) +# ✓ PHPArkitect (architecture validation) +``` -## Support -- **Issues**: [GitHub Issues](https://github.com/jblairy/php-benchmark/issues) -- **Discussions**: [GitHub Discussions](https://github.com/jblairy/php-benchmark/discussions) +### 📖 Developer Resources + +- **[CLAUDE.md](CLAUDE.md)** - Complete developer reference guide +- **[docs/guides/creating-benchmarks.md](docs/guides/creating-benchmarks.md)** - How to add benchmarks +- **[docs/guides/atomic-commits.md](docs/guides/atomic-commits.md)** - Commit best practices +- **[docs/architecture/](docs/architecture/)** - Architecture documentation + +### 🎯 Creating Custom Benchmarks + +Benchmarks are defined as **YAML files** in `fixtures/benchmarks/`. No PHP code changes needed! + +
+Click to see example benchmark + +```yaml +# fixtures/benchmarks/my-benchmark.yaml +slug: my-benchmark +name: 'My Custom Benchmark' +category: 'Custom' +description: 'Description of what this benchmark tests' +icon: 🚀 +tags: + - custom + - performance +phpVersions: + - php84 + - php85 +code: | + // Your benchmark code here + $result = []; + for ($i = 0; $i < 10000; $i++) { + $result[] = $i * 2; + } +``` -⭐ If you like this project, please give it a star! +**Load the new benchmark:** +```bash +make fixtures # Load fixtures only +# or +make db.refresh # Reset database + reload all benchmarks +``` +
+ +📖 **Full guide**: [docs/guides/creating-benchmarks.md](docs/guides/creating-benchmarks.md) + +## 🛠️ Tech Stack + + + + + + + + + + + + + + +
+ PHP
+ PHP 8.4+ +
+ Symfony
+ Symfony 7.2 +
+ Doctrine
+ Doctrine ORM +
+ Docker
+ Docker +
+ MariaDB
+ MariaDB +
+ Mercure
+ Mercure +
+ Chart.js
+ Chart.js +
+ Stimulus
+ Stimulus +
+ +### Quality Tools + +| Tool | Purpose | Level/Standard | +|------|---------|----------------| +| **PHPStan** | Static analysis | Level max (strictest) | +| **PHP-CS-Fixer** | Code style | PSR-12 + Symfony | +| **PHPUnit** | Unit testing | 50 tests, 212 assertions | +| **PHPArkitect** | Architecture validation | Clean Architecture rules | +| **PHPMD** | Mess detection | Custom ruleset | +| **Infection** | Mutation testing | Available | + +## 📊 Project Stats + +- **🎯 Benchmarks**: 107 automated tests +- **✅ Tests**: 50 passing (212 assertions) +- **📝 Code Quality**: PHPStan level max, PSR-12 compliant +- **🏗️ Architecture**: Clean Architecture + DDD + Hexagonal +- **🔄 PHP Versions**: Supports 5.6 to 8.5 +- **📚 Documentation**: 15+ detailed guides and ADRs + +## 📄 License + +This project is licensed under the **MIT License**. See the [LICENSE](LICENSE) file for details. + +## 🙏 Acknowledgments + +- **PHP Community** - For continuous inspiration and improvements +- **Contributors** - Thank you to everyone who has contributed to this project +- **Open Source Projects** - Built on the shoulders of giants + +### Key Dependencies + +Special thanks to the maintainers of: +- [Symfony](https://symfony.com/) - The PHP framework +- [Doctrine](https://www.doctrine-project.org/) - Database ORM +- [Spatie Async](https://github.com/spatie/async) - Parallel execution +- [Mercure](https://mercure.rocks/) - Real-time updates +- [PHPStan](https://phpstan.org/) - Static analysis + +## 📞 Support & Community + + + + + + + + + + + + + + +
🐛 IssuesReport bugs or request features
💬 DiscussionsAsk questions and share ideas
📧 ContactReach out via GitHub issues or discussions
+ +## 🌟 Show Your Support + +If this project helped you, please consider: + +- ⭐ **Star this repository** on GitHub +- 🔀 **Fork** and contribute +- 📢 **Share** with the PHP community +- 💡 **Open issues** for improvements + +--- + +
+ +**Made with ❤️ for the PHP Community** + +[![GitHub stars](https://img.shields.io/github/stars/jblairy/php_benchmark?style=social)](https://github.com/jblairy/php_benchmark) +[![GitHub forks](https://img.shields.io/github/forks/jblairy/php_benchmark?style=social)](https://github.com/jblairy/php_benchmark/fork) +[![GitHub watchers](https://img.shields.io/github/watchers/jblairy/php_benchmark?style=social)](https://github.com/jblairy/php_benchmark) + +
diff --git a/assets/controllers.json b/assets/controllers.json index 53e0ad0..003e3b9 100644 --- a/assets/controllers.json +++ b/assets/controllers.json @@ -6,6 +6,15 @@ "fetch": "eager" } }, + "@symfony/ux-live-component": { + "live": { + "enabled": true, + "fetch": "eager", + "autoimport": { + "@symfony/ux-live-component/dist/live.min.css": true + } + } + }, "@symfony/ux-turbo": { "turbo-core": { "enabled": true, diff --git a/assets/controllers/benchmark-card-live_controller.js b/assets/controllers/benchmark-card-live_controller.js new file mode 100644 index 0000000..744b1b0 --- /dev/null +++ b/assets/controllers/benchmark-card-live_controller.js @@ -0,0 +1,148 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Real-time benchmark card controller + * Updates individual cells when new data arrives via Mercure + * NO page reload, NO DOM replacement - just smooth value updates + */ +export default class extends Controller { + static values = { + benchmarkId: String, + benchmarkName: String + }; + + connect() { + + // Bind the event handler to this instance so we can remove it later + this.boundHandleEvent = this.handleEvent.bind(this); + + // Listen for Mercure updates on document (bubbles up) + document.addEventListener('benchmark:dataUpdated', this.boundHandleEvent); + } + + disconnect() { + + // Remove the event listener to prevent memory leaks + if (this.boundHandleEvent) { + document.removeEventListener('benchmark:dataUpdated', this.boundHandleEvent); + } + } + + handleEvent(event) { + this.handleDataUpdate(event.detail); + } + + handleDataUpdate(data) { + + // Only update if this card matches the updated benchmark + if (data.benchmarkId !== this.benchmarkIdValue) { + return; + } + + + // Update each PHP version's data + Object.entries(data.phpVersions || {}).forEach(([phpVersion, stats]) => { + this.updatePhpVersionStats(phpVersion, stats); + }); + + // Show visual feedback + this.flashUpdate(); + } + + updatePhpVersionStats(phpVersion, stats) { + + // Find the column for this PHP version + const headers = this.element.querySelectorAll('th.table__cell--metric'); + + let columnIndex = -1; + + headers.forEach((header, index) => { + const text = header.textContent.trim(); + if (text.includes(phpVersion.replace('php', ''))) { + columnIndex = index; + } + }); + + if (columnIndex === -1) { + console.warn(` ❌ Column not found for ${phpVersion}`); + return; + } + + + // Update each metric + this.updateCellValue('p50', columnIndex, stats.p50); + this.updateCellValue('p80', columnIndex, stats.p80); + this.updateCellValue('p90', columnIndex, stats.p90); + this.updateCellValue('p95', columnIndex, stats.p95); + this.updateCellValue('p99', columnIndex, stats.p99); + this.updateCellValue('avg', columnIndex, stats.avg); + this.updateCellValue('count', columnIndex, stats.count, false); // no decimal + } + + updateCellValue(metricName, columnIndex, newValue, hasDecimals = true) { + + // Find the row for this metric + const rows = this.element.querySelectorAll('tbody tr'); + + let targetCell = null; + + rows.forEach((row, rowIndex) => { + const metricCell = row.querySelector('[data-metric]'); + if (metricCell) { + const metric = metricCell.dataset.metric; + + if (metric === metricName) { + const cells = row.querySelectorAll('td.table__cell:not(.table__cell--metric)'); + targetCell = cells[columnIndex]; + } + } + }); + + if (!targetCell) { + console.warn(` ❌ Cell not found for ${metricName} column ${columnIndex}`); + return; + } + + // Get current value + const currentText = targetCell.textContent.trim().replace(/,/g, ''); + const currentValue = parseFloat(currentText); + + // Check if value actually changed + if (Math.abs(currentValue - newValue) < 0.00001) { + return; // No change + } + + // Format new value + const formattedValue = hasDecimals + ? newValue.toFixed(5).replace(/\.?0+$/, '') + : newValue.toString(); + + + // Animate the change + this.animateCellUpdate(targetCell, formattedValue); + + } + + animateCellUpdate(cell, newValue) { + // Add highlight animation + cell.classList.add('cell-updating'); + + // Update the value + cell.textContent = newValue; + + // Remove animation after it completes + setTimeout(() => { + cell.classList.remove('cell-updating'); + }, 1000); + } + + flashUpdate() { + // Flash the entire card briefly to show it was updated + this.element.style.transition = 'box-shadow 0.3s ease'; + this.element.style.boxShadow = '0 0 20px rgba(76, 175, 80, 0.5)'; + + setTimeout(() => { + this.element.style.boxShadow = ''; + }, 500); + } +} diff --git a/assets/controllers/benchmark-filter_controller.js b/assets/controllers/benchmark-filter_controller.js new file mode 100644 index 0000000..6859473 --- /dev/null +++ b/assets/controllers/benchmark-filter_controller.js @@ -0,0 +1,196 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Stimulus controller for filtering and sorting benchmarks without page reload + */ +export default class extends Controller { + static targets = ['card', 'searchInput', 'sortButton', 'categoryFilter', 'noResults']; + static values = { + currentSort: { type: String, default: 'name' }, + currentOrder: { type: String, default: 'asc' }, + searchQuery: { type: String, default: '' }, + selectedCategory: { type: String, default: 'all' } + }; + + connect() { + this.updateView(); + } + + search(event) { + this.searchQueryValue = event.target.value.toLowerCase(); + this.updateView(); + } + + filterByCategory(event) { + this.selectedCategoryValue = event.currentTarget.dataset.category; + this.updateCategoryButtonStates(); + this.updateView(); + } + + sort(event) { + const sortBy = event.currentTarget.dataset.sortBy; + + // Toggle order if clicking on the same sort button + if (this.currentSortValue === sortBy) { + this.currentOrderValue = this.currentOrderValue === 'asc' ? 'desc' : 'asc'; + } else { + this.currentSortValue = sortBy; + this.currentOrderValue = 'asc'; + } + + this.updateSortButtonStates(); + this.updateView(); + } + + resetFilters() { + this.searchQueryValue = ''; + this.selectedCategoryValue = 'all'; + this.currentSortValue = 'name'; + this.currentOrderValue = 'asc'; + + if (this.hasSearchInputTarget) { + this.searchInputTarget.value = ''; + } + + this.updateCategoryButtonStates(); + this.updateSortButtonStates(); + this.updateView(); + } + + updateView() { + const cards = this.getCardsWithData(); + + // Filter cards + const filteredCards = this.filterCards(cards); + + // Sort cards + const sortedCards = this.sortCards(filteredCards); + + // Hide all cards first + cards.forEach(({ element }) => { + element.style.display = 'none'; + }); + + // Show filtered and sorted cards + sortedCards.forEach(({ element }, index) => { + element.style.display = 'block'; + element.style.order = index; + }); + + // Show/hide no results message + this.toggleNoResults(sortedCards.length === 0); + + // Update stats + this.updateStats(sortedCards.length, cards.length); + } + + getCardsWithData() { + return this.cardTargets.map(element => { + const categoryEl = element.querySelector('.benchmark-card__category'); + const titleEl = element.querySelector('.benchmark-card__title'); + const codeEl = element.querySelector('.benchmark-card__code'); + const description = element.dataset.description || ''; + const tags = element.dataset.tags || ''; + + return { + element, + category: categoryEl ? categoryEl.textContent.trim().toLowerCase() : '', + name: titleEl ? titleEl.textContent.trim().toLowerCase() : '', + code: codeEl ? codeEl.textContent.trim().toLowerCase() : '', + description: description.toLowerCase(), + tags: tags.toLowerCase(), + fullText: (categoryEl?.textContent || '') + ' ' + + (titleEl?.textContent || '') + ' ' + + (codeEl?.textContent || '') + ' ' + + description + ' ' + + tags + }; + }); + } + + filterCards(cards) { + return cards.filter(card => { + // Search filter + const matchesSearch = !this.searchQueryValue || + card.fullText.toLowerCase().includes(this.searchQueryValue); + + // Category filter + const matchesCategory = this.selectedCategoryValue === 'all' || + card.category === this.selectedCategoryValue.toLowerCase(); + + return matchesSearch && matchesCategory; + }); + } + + sortCards(cards) { + return cards.sort((a, b) => { + let comparison = 0; + + switch (this.currentSortValue) { + case 'name': + comparison = a.name.localeCompare(b.name); + break; + case 'category': + comparison = a.category.localeCompare(b.category) || a.name.localeCompare(b.name); + break; + default: + comparison = 0; + } + + return this.currentOrderValue === 'asc' ? comparison : -comparison; + }); + } + + updateSortButtonStates() { + if (!this.hasSortButtonTarget) return; + + this.sortButtonTargets.forEach(button => { + const sortBy = button.dataset.sortBy; + const isActive = sortBy === this.currentSortValue; + + button.classList.toggle('filter__sort-button--active', isActive); + + // Update arrow icon + const icon = button.querySelector('.filter__sort-icon'); + if (icon && isActive) { + icon.textContent = this.currentOrderValue === 'asc' ? '↑' : '↓'; + } else if (icon) { + icon.textContent = '↕'; + } + }); + } + + updateCategoryButtonStates() { + if (!this.hasCategoryFilterTarget) return; + + this.categoryFilterTargets.forEach(button => { + const category = button.dataset.category; + const isActive = category === this.selectedCategoryValue; + + button.classList.toggle('filter__category-button--active', isActive); + }); + } + + toggleNoResults(show) { + if (this.hasNoResultsTarget) { + this.noResultsTarget.style.display = show ? 'block' : 'none'; + } + } + + updateStats(visible, total) { + const statsEl = document.querySelector('.filter__stats'); + if (statsEl) { + if (visible === total) { + statsEl.textContent = `${total} benchmark${total !== 1 ? 's' : ''}`; + } else { + statsEl.textContent = `${visible} / ${total} benchmark${total !== 1 ? 's' : ''}`; + } + } + } + + getUniqueCategories() { + const cards = this.getCardsWithData(); + const categories = [...new Set(cards.map(card => card.category))].filter(c => c); + return categories.sort(); + } +} diff --git a/assets/controllers/benchmark_chart_controller.js b/assets/controllers/benchmark_chart_controller.js deleted file mode 100644 index c9827c8..0000000 --- a/assets/controllers/benchmark_chart_controller.js +++ /dev/null @@ -1,79 +0,0 @@ -import { Controller } from '@hotwired/stimulus'; -import { Chart } from 'chart.js'; - -export default class extends Controller { - static targets = ['chart', 'container']; - static values = { - labels: Array, - p50Data: Array, - p90Data: Array, - avgData: Array - } - - connect() { - this.initializeChart(); - } - - initializeChart() { - const ctx = this.chartTarget.getContext('2d'); - - new Chart(ctx, { - type: 'bar', - data: { - labels: this.labelsValue, - datasets: [ - { - label: 'p50 (ms)', - data: this.p50DataValue, - backgroundColor: 'rgba(54, 162, 235, 0.5)', - borderColor: 'rgba(54, 162, 235, 1)', - borderWidth: 1 - }, - { - label: 'p90 (ms)', - data: this.p90DataValue, - backgroundColor: 'rgba(255, 159, 64, 0.5)', - borderColor: 'rgba(255, 159, 64, 1)', - borderWidth: 1 - }, - { - label: 'Moyenne (ms)', - data: this.avgDataValue, - backgroundColor: 'rgba(75, 192, 192, 0.5)', - borderColor: 'rgba(75, 192, 192, 1)', - borderWidth: 1 - } - ] - }, - options: { - scales: { - y: { - beginAtZero: true, - title: { - display: true, - text: 'Temps d\'exécution (ms)' - } - } - }, - plugins: { - title: { - display: true, - text: 'Comparaison des performances par version PHP' - }, - tooltip: { - callbacks: { - label: function(context) { - return context.dataset.label + ': ' + context.parsed.y.toFixed(5) + ' ms'; - } - } - } - } - } - }); - } - - toggleChart(event) { - event.preventDefault(); - this.containerTarget.classList.toggle('chart__container--visible'); - } -} diff --git a/assets/controllers/dashboard-mercure_controller.js b/assets/controllers/dashboard-mercure_controller.js new file mode 100644 index 0000000..bc0aefb --- /dev/null +++ b/assets/controllers/dashboard-mercure_controller.js @@ -0,0 +1,132 @@ +import { Controller } from '@hotwired/stimulus'; +import { getComponent } from '@symfony/ux-live-component'; + +/** + * Stimulus controller for real-time dashboard updates via Mercure + * Shows notification banner when benchmarks complete, lets user decide when to refresh + */ +export default class extends Controller { + static values = { + url: String, + topic: String + }; + + static targets = ['banner', 'bannerText']; + + eventSource = null; + pendingUpdates = 0; + reconnectAttempts = 0; + maxReconnectAttempts = 5; + reconnectDelay = 1000; // Start with 1 second + + connect() { + + this.subscribeToMercure(); + } + + disconnect() { + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + } + + subscribeToMercure() { + const url = new URL(this.urlValue); + url.searchParams.append('topic', this.topicValue); + + + // Close existing connection if any + if (this.eventSource) { + this.eventSource.close(); + } + + this.eventSource = new EventSource(url); + + this.eventSource.onopen = () => { + // Reset reconnection attempts on successful connection + this.reconnectAttempts = 0; + this.reconnectDelay = 1000; + }; + + this.eventSource.onmessage = (event) => { + const data = JSON.parse(event.data); + this.handleBenchmarkEvent(data); + }; + + this.eventSource.onerror = (error) => { + console.error('❌ Mercure connection error:', error); + + // Check if EventSource is closed + if (this.eventSource.readyState === EventSource.CLOSED) { + this.attemptReconnection(); + } + }; + } + + attemptReconnection() { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error('❌ Max reconnection attempts reached. Please refresh the page.'); + return; + } + + this.reconnectAttempts++; + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); // Exponential backoff + + + setTimeout(() => { + this.subscribeToMercure(); + }, delay); + } + + handleBenchmarkEvent(data) { + if (data.type === 'benchmark.completed') { + this.onBenchmarkCompleted(data); + } + } + + async onBenchmarkCompleted(data) { + + // Wait a bit for database to be updated + await new Promise(resolve => setTimeout(resolve, 500)); + + // Find the matching card component and reload it + this.reloadMatchingCard(data.benchmarkId, data.benchmarkName); + } + + async reloadMatchingCard(benchmarkId, benchmarkName) { + + // Find all BenchmarkCard components + const cards = document.querySelectorAll('[data-live-name-value="BenchmarkCard"]'); + + for (const card of cards) { + // Get benchmarkId from the props + const propsJson = card.dataset.livePropsValue; + if (!propsJson) continue; + + try { + const props = JSON.parse(propsJson); + const cardBenchmarkId = props.benchmarkId; + + + if (cardBenchmarkId === benchmarkId) { + + const component = await getComponent(card); + + component.render().then(() => { + }).catch(error => { + console.error(' ❌ Failed to refresh card:', error); + }); + + break; // Found the match, stop searching + } + } catch (error) { + console.error(' ❌ Error parsing props:', error); + } + } + } + + refreshNow() { + window.location.reload(); + } +} diff --git a/assets/controllers/mercure-progress_controller.js b/assets/controllers/mercure-progress_controller.js new file mode 100644 index 0000000..4a58303 --- /dev/null +++ b/assets/controllers/mercure-progress_controller.js @@ -0,0 +1,129 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Stimulus controller for real-time benchmark progress updates via Mercure + */ +export default class extends Controller { + static values = { + url: String, + topic: String + }; + + eventSource = null; + + connect() { + this.subscribeToMercure(); + } + + disconnect() { + if (this.eventSource) { + this.eventSource.close(); + } + } + + subscribeToMercure() { + const url = new URL(this.urlValue); + url.searchParams.append('topic', this.topicValue); + + + this.eventSource = new EventSource(url); + + this.eventSource.onopen = () => { + }; + + this.eventSource.onmessage = (event) => { + const data = JSON.parse(event.data); + this.handleBenchmarkUpdate(data); + }; + + this.eventSource.onerror = (error) => { + console.error('❌ Mercure connection error:', error); + console.error('ReadyState:', this.eventSource.readyState); + }; + } + + handleBenchmarkUpdate(data) { + + switch (data.type) { + case 'benchmark.started': + this.showStarted(data); + break; + + case 'benchmark.progress': + this.showProgress(data); + break; + + case 'benchmark.completed': + this.showCompleted(data); + break; + } + } + + showStarted(data) { + // Update benchmark name and PHP version + const nameEl = this.element.querySelector('.benchmark-progress__name'); + if (nameEl) { + nameEl.textContent = `${data.benchmarkName} (${data.benchmarkId})`; + } + + const badgeEl = this.element.querySelector('.benchmark-progress__badge'); + if (badgeEl) { + badgeEl.textContent = data.phpVersion.toUpperCase(); + badgeEl.classList.remove('benchmark-progress__badge--hidden'); + } + + // Hide all status divs + this.hideAllStatus(); + + // Show started status + const startedEl = this.element.querySelector('.benchmark-progress__status--started'); + if (startedEl) { + startedEl.style.display = 'block'; + } + + } + + showProgress(data) { + // Hide all status divs + this.hideAllStatus(); + + // Show running status + const runningEl = this.element.querySelector('.benchmark-progress__status--running'); + if (runningEl) { + runningEl.style.display = 'block'; + } + + // Update progress bar + const progress = data.totalIterations > 0 + ? (data.currentIteration / data.totalIterations) * 100 + : 0; + + const progressBar = this.element.querySelector('.benchmark-progress__progress-bar'); + if (progressBar) { + progressBar.style.width = `${progress}%`; + } + + const progressText = this.element.querySelector('.benchmark-progress__progress-text'); + if (progressText) { + progressText.textContent = `${data.currentIteration} / ${data.totalIterations}`; + } + + } + + showCompleted(data) { + // Hide all status divs + this.hideAllStatus(); + + // Show completed status + const completedEl = this.element.querySelector('.benchmark-progress__status--completed'); + if (completedEl) { + completedEl.style.display = 'block'; + } + + } + + hideAllStatus() { + const statusDivs = this.element.querySelectorAll('[class*="benchmark-progress__status--"]'); + statusDivs.forEach(el => el.style.display = 'none'); + } +} diff --git a/assets/controllers/toggle_controller.js b/assets/controllers/toggle_controller.js index 4c884e9..2b009b0 100644 --- a/assets/controllers/toggle_controller.js +++ b/assets/controllers/toggle_controller.js @@ -1,9 +1,40 @@ import { Controller } from '@hotwired/stimulus'; +/* + * Controller for toggling between chart and table view + */ export default class extends Controller { - static targets = ['chart'] + static targets = ['chart', 'table', 'iconChart', 'iconTable', 'button']; + + connect() { + this.showingChart = false; + } toggle() { - this.chartTarget.classList.toggle('chart__container--visible'); + this.showingChart = !this.showingChart; + + if (this.showingChart) { + // Show chart, hide table + this.tableTarget.classList.add('benchmark-card__table-wrapper--hidden'); + this.chartTarget.classList.add('benchmark-card__chart-container--visible'); + + // Switch icons + this.iconChartTarget.style.display = 'none'; + this.iconTableTarget.style.display = 'inline'; + + // Update title + this.buttonTarget.setAttribute('title', 'Afficher le tableau'); + } else { + // Show table, hide chart + this.tableTarget.classList.remove('benchmark-card__table-wrapper--hidden'); + this.chartTarget.classList.remove('benchmark-card__chart-container--visible'); + + // Switch icons + this.iconChartTarget.style.display = 'inline'; + this.iconTableTarget.style.display = 'none'; + + // Update title + this.buttonTarget.setAttribute('title', 'Afficher le graphique'); + } } } diff --git a/assets/controllers/unit_switch_controller.js b/assets/controllers/unit_switch_controller.js index 1c642dc..d9cc65b 100644 --- a/assets/controllers/unit_switch_controller.js +++ b/assets/controllers/unit_switch_controller.js @@ -1,16 +1,28 @@ // assets/controllers/unit-switch_controller.js import { Controller } from '@hotwired/stimulus'; +/** + * Global unit switch controller - controls all benchmark cards at once + */ export default class extends Controller { - static targets = ['cell']; + static targets = ['cell', 'msButton', 'nsButton']; static values = { currentUnit: { type: String, default: 'ms' } } - toggle() { - const newUnit = this.currentUnitValue === 'ms' ? 'ns' : 'ms'; - this.currentUnitValue = newUnit; + connect() { + this.updateButtonStates(); + } + + toggle(event) { + const clickedUnit = event.currentTarget.dataset.unit; + + // Don't toggle if clicking the already active unit + if (clickedUnit === this.currentUnitValue) return; + + this.currentUnitValue = clickedUnit; + // Update all cells across all cards this.cellTargets.forEach(cell => { const value = parseFloat(cell.dataset.value); if (!isNaN(value)) { @@ -19,14 +31,35 @@ export default class extends Controller { } }); - // Mise à jour du libellé de l'unité dans l'en-tête - const metrics = ['p50', 'p80', 'p90', 'p95', 'p99', 'Moyenne']; + // Update all metric headers across all cards + const metrics = [ + { key: 'p50', label: 'p50' }, + { key: 'p80', label: 'p80' }, + { key: 'p90', label: 'p90' }, + { key: 'p95', label: 'p95' }, + { key: 'p99', label: 'p99' }, + { key: 'avg', label: 'Moyenne' } + ]; + metrics.forEach(metric => { - const header = this.element.querySelector(`[data-metric="${metric}"]`); - if (header) { - header.textContent = `${metric} (${this.currentUnitValue})`; - } + document.querySelectorAll(`[data-metric="${metric.key}"]`).forEach(header => { + header.textContent = `${metric.label} (${this.currentUnitValue})`; + }); }); + + this.updateButtonStates(); + } + + updateButtonStates() { + if (this.hasMsButtonTarget && this.hasNsButtonTarget) { + if (this.currentUnitValue === 'ms') { + this.msButtonTarget.classList.add('filter__unit-toggle-option--active'); + this.nsButtonTarget.classList.remove('filter__unit-toggle-option--active'); + } else { + this.msButtonTarget.classList.remove('filter__unit-toggle-option--active'); + this.nsButtonTarget.classList.add('filter__unit-toggle-option--active'); + } + } } formatValue(value) { diff --git a/assets/styles/_layout.scss b/assets/styles/_layout.scss index 0faf612..1b526ae 100644 --- a/assets/styles/_layout.scss +++ b/assets/styles/_layout.scss @@ -1,8 +1,19 @@ +// Layout styles +// Follows BEM methodology: Block__Element--Modifier + .dashboard { &__container { - max-width: $breakpoint-desktop; + max-width: 100%; margin: 0 auto; padding: $spacing-lg; + + @media (min-width: $breakpoint-xlarge) { + max-width: $breakpoint-large; + } + + @media (min-width: $breakpoint-ultra) { + max-width: $breakpoint-xlarge; + } } &__title { @@ -14,4 +25,27 @@ color: $color-text; margin-bottom: $spacing-lg; } + + &__subtitle-info { + color: $color-success; + font-weight: 500; + } + + &__benchmarks { + display: flex; + flex-direction: column; + gap: $spacing-md; + } +} + +// Benchmark list container +.benchmark-list { + display: flex; + flex-direction: column; + gap: $spacing-md; + + > div { + display: flex; + flex-direction: column; + } } diff --git a/assets/styles/_variables.scss b/assets/styles/_variables.scss index 4626010..3f49f27 100644 --- a/assets/styles/_variables.scss +++ b/assets/styles/_variables.scss @@ -20,8 +20,13 @@ $spacing-xxl: 40px; $breakpoint-tablet: 768px; $breakpoint-desktop: 1200px; $breakpoint-large: 1600px; +$breakpoint-xlarge: 1920px; +$breakpoint-ultra: 2560px; // Ombres $shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1); $shadow-md: 0 2px 3px rgba(0, 0, 0, 0.1); $shadow-lg: 0 2px 5px rgba(0, 0, 0, 0.1); +$shadow-card: 0 4px 12px rgba(0, 0, 0, 0.08); +$shadow-card-hover: 0 8px 20px rgba(0, 0, 0, 0.12); +$shadow-badge: 0 4px 12px rgba(102, 126, 234, 0.4); diff --git a/assets/styles/app.scss b/assets/styles/app.scss index c6bfe9c..f363159 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -1,15 +1,37 @@ +// Main SCSS entry point - All styles follow BEM: Block__Element--Modifier @import "variables"; @import "layout"; -@import "components/table"; @import "components/card"; -@import "components/buttons"; @import "components/chart"; +@import "components/benchmark-card"; +@import "components/benchmark-progress"; +@import "components/header"; +@import "components/footer"; +@import "components/filter"; +@import "components/stats-overview"; -.empty-message { - text-align: center; - padding: $spacing-lg; - color: $color-text-secondary; - background-color: white; - border-radius: 8px; - box-shadow: $shadow-md; +// Global body styles +.body { + margin: 0; + padding: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + background: linear-gradient(180deg, #f7fafc 0%, #edf2f7 100%); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + + &__main { + flex: 1; + width: 100%; + } +} + +// Utility classes +.spinner { + display: inline-block; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } } diff --git a/assets/styles/components/_benchmark-card.scss b/assets/styles/components/_benchmark-card.scss new file mode 100644 index 0000000..cf510cc --- /dev/null +++ b/assets/styles/components/_benchmark-card.scss @@ -0,0 +1,425 @@ +// BenchmarkCard component styles +// Follows BEM methodology: Block__Element--Modifier + +.benchmark-card { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: visible; + animation: slideInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1) both; + + // Cascade effect - each card appears with a delay + @for $i from 1 through 20 { + &:nth-child(#{$i}) { + animation-delay: #{$i * 0.08}s; + } + } + + // Loading indicator overlay - non-intrusive + &__loading-overlay { + position: absolute; + top: 0; + right: 0; + padding: 0.5rem; + pointer-events: none; + z-index: 10; + display: none; + } + + &__loading-spinner { + width: 24px; + height: 24px; + border: 3px solid rgba(16, 185, 129, 0.2); + border-top-color: #10b981; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + // Initial loading state + &__initial-loading { + padding: 2rem; + text-align: center; + animation: fadeIn 0.3s ease-in; + } + + // Header section + &__header { + margin: (-$spacing-md) (-$spacing-md) $spacing-lg (-$spacing-md); + padding: $spacing-xl $spacing-md $spacing-lg $spacing-md; + background: linear-gradient(135deg, #fafbfc 0%, #ffffff 100%); + border-radius: 12px 12px 0 0; + border-bottom: 2px solid #e2e8f0; + } + + &__header-top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: $spacing-md; + } + + &__toggle-button { + background: white; + border: 2px solid #e2e8f0; + border-radius: 8px; + padding: 8px 12px; + font-size: 1.2rem; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + border-color: #667eea; + } + + &:active { + transform: translateY(0); + } + } + + &__category-badge { + display: inline-block; + padding: 6px 14px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + border-radius: 16px; + margin-bottom: $spacing-md; + box-shadow: $shadow-badge; + transition: all 0.3s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(102, 126, 234, 0.5); + } + } + + &__title { + font-size: 1.75rem; + font-weight: 700; + color: #1a202c; + margin: 0; + line-height: 1.3; + display: flex; + align-items: center; + gap: 10px; + } + + + // Description section + &__description { + color: #4a5568; + font-size: 0.95rem; + line-height: 1.6; + margin-bottom: $spacing-lg; + padding: $spacing-md $spacing-lg; + background: linear-gradient(to right, #f7fafc, #ffffff); + border-left: 4px solid #667eea; + border-radius: 8px; + font-style: italic; + } + + // Tags section + &__tags { + display: flex; + flex-wrap: wrap; + gap: $spacing-xs; + margin-bottom: $spacing-md; + } + + &__tag { + display: inline-block; + padding: 0.25rem 0.75rem; + background: #edf2f7; + color: #4a5568; + font-size: 0.8rem; + font-weight: 500; + border-radius: 16px; + border: 1px solid #e2e8f0; + transition: all 0.2s ease; + + &:hover { + background: #e2e8f0; + border-color: #cbd5e0; + } + } + + // Code section + &__code-section { + background: #2d3748; + border-radius: 8px; + overflow: hidden; + margin-bottom: $spacing-lg; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + } + + &__code-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: $spacing-sm $spacing-md; + background: #1a202c; + border-bottom: 1px solid #4a5568; + } + + &__code-label { + color: #e2e8f0; + font-size: 0.875rem; + font-weight: 600; + } + + &__code-lang { + color: #9ca3af; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + } + + &__code { + margin: 0; + padding: $spacing-lg; + background: #2d3748; + color: #e2e8f0; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace; + font-size: 0.875rem; + line-height: 1.6; + overflow-x: auto; + + code { + color: #e2e8f0; + } + } + + // Table wrapper with horizontal scroll + &__table-wrapper { + overflow-x: auto; + overflow-y: visible; + margin: 0.5rem 0; + border-radius: 8px; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + scrollbar-width: thin; + scrollbar-color: #cbd5e1 #f1f5f9; + + &::-webkit-scrollbar { + height: 8px; + } + + &::-webkit-scrollbar-track { + background: #f1f5f9; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 4px; + transition: background 0.2s; + + &:hover { + background: #94a3b8; + } + } + + // Scroll hint gradient + &::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 8px; + width: 40px; + background: linear-gradient(to left, white, transparent); + pointer-events: none; + opacity: 0; + transition: opacity 0.3s; + } + + &:not(:hover)::after { + opacity: 1; + } + + // Remove shadow on first column when not scrolled + &:not(.scrolled) .benchmark-card__table-cell--metric { + box-shadow: none; + } + } + + // Table + &__table { + border-collapse: separate; + border-spacing: 0; + border-radius: 8px; + overflow: hidden; + width: max-content; + min-width: 100%; + background: white; + } + + &__table-header { + background-color: $color-background; + font-weight: bold; + color: $color-text-secondary; + } + + &__table-row { + transition: background-color 0.2s ease; + + &:hover { + background-color: rgba(0, 0, 0, 0.02); + } + } + + &__table-cell { + min-width: 85px; + width: 85px; + text-align: right; + font-variant-numeric: tabular-nums; + transition: background-color 0.3s ease, color 0.3s ease; + padding: 0.5rem 0.75rem; + white-space: nowrap; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace; + font-size: 0.95em; + letter-spacing: 0.02em; + + &--metric { + text-align: left; + min-width: 180px; + width: 180px; + font-weight: 500; + position: sticky; + left: 0; + background: white; + z-index: 2; + box-shadow: 2px 0 4px rgba(0, 0, 0, 0.05); + font-family: inherit; + font-size: 1em; + letter-spacing: normal; + } + + &--best-value { + background-color: rgba(16, 185, 129, 0.1); + font-weight: 600; + position: relative; + + &::before { + content: '★'; + position: absolute; + left: 8px; + color: #10b981; + font-size: 0.8em; + } + } + + &--updating { + animation: cellPulse 1s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + } + } + + // Chart container - toggleable with table + &__chart-container { + display: none; + padding: $spacing-lg 0; + margin-bottom: $spacing-lg; + + &--visible { + display: flex; + align-items: center; + justify-content: center; + } + + canvas { + max-height: 350px !important; + width: auto !important; + height: auto !important; + } + } + + // Loading state animation + &--loading { + animation: cardReload 0.6s ease-in-out; + } + + // Table wrapper - simple toggle + &__table-wrapper { + display: block; + + &--hidden { + display: none; + } + } +} + +// Animations +@keyframes spin { + to { transform: rotate(360deg); } +} + +@keyframes cardReload { + 0% { + opacity: 1; + } + 50% { + opacity: 0.7; + } + 100% { + opacity: 1; + } +} + +@keyframes cellPulse { + 0% { + background-color: #10b981; + color: white; + transform: scale(1); + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); + } + 50% { + background-color: #34d399; + color: white; + transform: scale(1.02); + box-shadow: 0 0 0 8px rgba(16, 185, 129, 0); + } + 100% { + background-color: transparent; + color: inherit; + transform: scale(1); + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes expandIn { + from { + max-height: 0; + opacity: 0; + } + to { + max-height: 500px; + opacity: 1; + } +} + +@keyframes slideInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/assets/styles/components/_benchmark-progress.scss b/assets/styles/components/_benchmark-progress.scss new file mode 100644 index 0000000..7982484 --- /dev/null +++ b/assets/styles/components/_benchmark-progress.scss @@ -0,0 +1,117 @@ +// BenchmarkProgress component styles +// Follows BEM methodology: Block__Element--Modifier + +.benchmark-progress { + border: 1px solid $color-border; + border-radius: 8px; + padding: $spacing-lg; + margin: $spacing-lg 0; + background: white; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: $spacing-lg; + } + + &__name { + margin: 0; + font-size: 1.2rem; + } + + &__badge { + background: $color-primary; + color: white; + padding: 0.25rem 0.75rem; + border-radius: 4px; + font-size: 0.875rem; + font-weight: bold; + + &--hidden { + display: none; + } + } + + // Status sections + &__status { + &--idle { + p { + color: #999; + font-style: italic; + } + } + + &--started, + &--running, + &--completed { + &[style*="display: none"] { + display: none; + } + } + } + + &__status-text { + margin: 0.5rem 0; + color: $color-text-secondary; + + &--success { + color: $color-success; + font-weight: bold; + } + } + + &__info-text { + margin: 0.5rem 0; + color: $color-text-secondary; + } + + // Progress bar + &__progress-container { + width: 100%; + height: 30px; + background: $color-background-light; + border-radius: 4px; + overflow: hidden; + margin-bottom: 0.5rem; + } + + &__progress-bar { + height: 100%; + background: linear-gradient(90deg, $color-success, #8bc34a); + transition: width 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + } + + &__progress-text { + color: white; + font-weight: bold; + font-size: 0.875rem; + } + + // Results grid + &__results-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: $spacing-lg; + margin: $spacing-lg 0; + } + + &__result-item { + display: flex; + flex-direction: column; + } + + &__result-label { + font-size: 0.875rem; + color: $color-text-secondary; + } + + &__result-value { + font-size: 1.25rem; + font-weight: bold; + color: $color-text; + } +} diff --git a/assets/styles/components/_buttons.scss b/assets/styles/components/_buttons.scss deleted file mode 100644 index 3dc06f4..0000000 --- a/assets/styles/components/_buttons.scss +++ /dev/null @@ -1,15 +0,0 @@ -.button { - &__toggle { - margin-left: $spacing-sm; - padding: 6px 12px; - background-color: $color-text-secondary; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - - &:hover { - background-color: darken($color-text-secondary, 10%); - } - } -} diff --git a/assets/styles/components/_card.scss b/assets/styles/components/_card.scss index 4275b28..e9656b7 100644 --- a/assets/styles/components/_card.scss +++ b/assets/styles/components/_card.scss @@ -1,35 +1,18 @@ +// Stats card component styles (generic wrapper for statistics displays) +// Follows BEM methodology: Block__Element--Modifier + .stats-card { &__container { background-color: $color-background; - border-radius: 5px; - padding: $spacing-lg; - margin-bottom: $spacing-lg; - box-shadow: $shadow-md; - } - - &__flex { - display: flex; - justify-content: space-between; - align-items: center; - gap: $spacing-md; - } - - &__item { - padding: $spacing-sm; - border-radius: 4px; - background-color: white; - box-shadow: $shadow-sm; - } - - &__label { - font-size: 0.9rem; - color: $color-text-secondary; - margin-bottom: $spacing-xs; - } - - &__value { - font-size: 1.2rem; - font-weight: bold; - color: $color-text; + border-radius: 12px; + padding: $spacing-md; + margin-bottom: $spacing-md; + box-shadow: $shadow-card; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + &:hover { + transform: translateY(-4px); + box-shadow: $shadow-card-hover; + } } } diff --git a/assets/styles/components/_chart.scss b/assets/styles/components/_chart.scss index 807db32..2df32c4 100644 --- a/assets/styles/components/_chart.scss +++ b/assets/styles/components/_chart.scss @@ -1,3 +1,6 @@ +// Chart component styles +// Follows BEM methodology: Block__Element--Modifier + .chart { &__container { height: 300px; diff --git a/assets/styles/components/_filter.scss b/assets/styles/components/_filter.scss new file mode 100644 index 0000000..f6d8396 --- /dev/null +++ b/assets/styles/components/_filter.scss @@ -0,0 +1,284 @@ +// Filter component styles +// Follows BEM methodology: Block__Element--Modifier + +.filter { + margin: $spacing-xxl 0; + + &__container { + background: white; + border-radius: 12px; + padding: $spacing-lg; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + border: 1px solid #e2e8f0; + } + + // Search Section + &__search-section { + display: grid; + grid-template-columns: 1fr auto; + gap: $spacing-md; + margin-bottom: $spacing-lg; + } + + &__search-wrapper { + min-width: 0; + position: relative; + } + + &__search-icon { + position: absolute; + left: $spacing-md; + top: 50%; + transform: translateY(-50%); + font-size: 1.25rem; + pointer-events: none; + } + + &__search-input { + width: 100%; + padding: $spacing-md $spacing-md $spacing-md calc($spacing-md * 3); + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 1rem; + transition: all 0.2s ease; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); + } + + &::placeholder { + color: #a0aec0; + } + } + + &__reset-button { + padding: $spacing-md $spacing-lg; + background: #f7fafc; + border: 2px solid #e2e8f0; + border-radius: 8px; + color: #4a5568; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + flex-shrink: 0; + + &:hover { + background: #edf2f7; + border-color: #cbd5e0; + } + + &:active { + transform: scale(0.98); + } + } + + // Controls Section + &__controls { + display: flex; + gap: $spacing-xl; + margin-bottom: $spacing-lg; + flex-wrap: wrap; + align-items: center; + } + + &__label { + font-weight: 600; + color: #4a5568; + margin-right: $spacing-sm; + } + + // Sort Group + &__sort-group { + display: flex; + align-items: center; + gap: $spacing-sm; + flex-wrap: wrap; + } + + &__sort-button { + padding: $spacing-sm $spacing-md; + background: white; + border: 2px solid #e2e8f0; + border-radius: 8px; + color: #4a5568; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: $spacing-xs; + + &:hover { + border-color: #cbd5e0; + background: #f7fafc; + } + + &--active { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-color: transparent; + + &:hover { + opacity: 0.9; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + } + } + } + + &__sort-icon { + font-size: 1rem; + font-weight: bold; + } + + // Category Group + &__category-group { + display: flex; + align-items: center; + gap: $spacing-sm; + flex-wrap: wrap; + } + + &__category-button { + padding: $spacing-sm $spacing-md; + background: white; + border: 2px solid #e2e8f0; + border-radius: 20px; + color: #4a5568; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + border-color: #cbd5e0; + background: #f7fafc; + } + + &--active { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-color: transparent; + + &:hover { + opacity: 0.9; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + } + } + } + + // Stats + &__stats-container { + padding-top: $spacing-md; + border-top: 1px solid #e2e8f0; + display: flex; + justify-content: space-between; + align-items: center; + gap: $spacing-md; + flex-wrap: wrap; + } + + &__stats { + color: #718096; + font-size: 0.875rem; + font-weight: 500; + } + + // Unit Toggle - Compact pill-shaped design + &__unit-toggle { + display: inline-flex; + background: white; + border: 2px solid #e2e8f0; + border-radius: 20px; + overflow: hidden; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + } + + &__unit-toggle-option { + padding: 6px 16px; + border: none; + background: transparent; + color: #4a5568; + font-weight: 500; + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s ease; + text-transform: lowercase; + border-right: 1px solid #e2e8f0; + + &:last-child { + border-right: none; + } + + &:hover:not(&--active) { + background: #f7fafc; + } + + &--active { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-right-color: transparent; + + &:hover { + opacity: 0.9; + } + } + } + + + + // No Results + &__no-results { + background: white; + border-radius: 12px; + padding: $spacing-xxl * 2; + text-align: center; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + border: 2px dashed #e2e8f0; + } + + &__no-results-icon { + font-size: 4rem; + margin-bottom: $spacing-lg; + opacity: 0.5; + } + + &__no-results-title { + font-size: 1.5rem; + color: #2d3748; + margin: 0 0 $spacing-md 0; + } + + &__no-results-text { + color: #718096; + margin: 0; + font-size: 1rem; + } +} + +// Responsive adjustments +@media (max-width: $breakpoint-tablet) { + .filter { + &__search-section { + grid-template-columns: 1fr; + } + + &__reset-button { + width: 100%; + } + + &__controls { + flex-direction: column; + align-items: flex-start; + gap: $spacing-lg; + } + + &__sort-group, + &__category-group { + width: 100%; + } + } +} + diff --git a/assets/styles/components/_footer.scss b/assets/styles/components/_footer.scss new file mode 100644 index 0000000..bf0431d --- /dev/null +++ b/assets/styles/components/_footer.scss @@ -0,0 +1,129 @@ +// Footer component styles +// Follows BEM methodology: Block__Element--Modifier + +.footer { + background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%); + color: #e2e8f0; + margin-top: $spacing-xxl * 2; + box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1); + + &__container { + max-width: 100%; + margin: 0 auto; + padding: $spacing-xxl $spacing-xl; + + @media (min-width: $breakpoint-xlarge) { + max-width: $breakpoint-large; + } + + @media (min-width: $breakpoint-ultra) { + max-width: $breakpoint-xlarge; + } + } + + &__content { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: $spacing-xxl; + margin-bottom: $spacing-xxl; + } + + &__section { + display: flex; + flex-direction: column; + gap: $spacing-md; + } + + &__section-title { + font-size: 1.125rem; + font-weight: 600; + margin: 0 0 $spacing-sm 0; + color: white; + } + + &__description { + line-height: 1.6; + opacity: 0.8; + margin: 0; + } + + &__link { + color: #e2e8f0; + text-decoration: none; + opacity: 0.8; + transition: opacity 0.2s ease, transform 0.2s ease; + display: inline-flex; + align-items: center; + gap: $spacing-xs; + + &:hover { + opacity: 1; + transform: translateX(4px); + } + + &::before { + content: '→'; + opacity: 0; + transition: opacity 0.2s ease; + } + + &:hover::before { + opacity: 1; + } + } + + &__stats { + display: flex; + gap: $spacing-lg; + flex-wrap: wrap; + } + + &__stat { + display: flex; + flex-direction: column; + gap: $spacing-xs; + } + + &__stat-value { + font-size: 1.5rem; + font-weight: 700; + color: #667eea; + } + + &__stat-label { + font-size: 0.875rem; + opacity: 0.7; + } + + &__bottom { + border-top: 1px solid rgba(255, 255, 255, 0.1); + padding-top: $spacing-lg; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: $spacing-md; + } + + &__copyright { + opacity: 0.7; + font-size: 0.875rem; + } + + &__social { + display: flex; + gap: $spacing-md; + } + + &__social-link { + color: #e2e8f0; + font-size: 1.25rem; + opacity: 0.7; + transition: opacity 0.2s ease, transform 0.2s ease; + + &:hover { + opacity: 1; + transform: translateY(-2px); + } + } +} diff --git a/assets/styles/components/_header.scss b/assets/styles/components/_header.scss new file mode 100644 index 0000000..9a307c1 --- /dev/null +++ b/assets/styles/components/_header.scss @@ -0,0 +1,90 @@ +// Header component styles +// Follows BEM methodology: Block__Element--Modifier + +.header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + position: sticky; + top: 0; + z-index: 100; + + &__container { + max-width: 100%; + margin: 0 auto; + padding: $spacing-lg $spacing-xl; + display: flex; + justify-content: space-between; + align-items: center; + + @media (min-width: $breakpoint-xlarge) { + max-width: $breakpoint-large; + } + + @media (min-width: $breakpoint-ultra) { + max-width: $breakpoint-xlarge; + } + } + + &__brand { + display: flex; + align-items: center; + gap: $spacing-md; + text-decoration: none; + color: white; + transition: transform 0.2s ease; + + &:hover { + transform: translateY(-2px); + } + } + + &__logo { + font-size: 2rem; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); + } + + &__title { + font-size: 1.5rem; + font-weight: 700; + margin: 0; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } + + &__subtitle { + font-size: 0.875rem; + opacity: 0.9; + margin: 0; + } + + &__nav { + display: flex; + gap: $spacing-lg; + align-items: center; + } + + &__nav-link { + color: white; + text-decoration: none; + padding: $spacing-sm $spacing-md; + border-radius: 4px; + transition: background-color 0.2s ease; + font-weight: 500; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + + &--active { + background-color: rgba(255, 255, 255, 0.2); + } + } + + &__badge { + background-color: rgba(255, 255, 255, 0.2); + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + backdrop-filter: blur(10px); + } +} diff --git a/assets/styles/components/_stats-overview.scss b/assets/styles/components/_stats-overview.scss new file mode 100644 index 0000000..c11505e --- /dev/null +++ b/assets/styles/components/_stats-overview.scss @@ -0,0 +1,74 @@ +.stats-overview { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; + margin: 2rem 0; + padding: 0; +} + +.stats-overview__card { + background: white; + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + gap: 1rem; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } +} + +.stats-overview__icon { + font-size: 2.5rem; + line-height: 1; + flex-shrink: 0; +} + +.stats-overview__content { + flex: 1; +} + +.stats-overview__value { + font-size: 2rem; + font-weight: 700; + color: #2c3e50; + line-height: 1; + margin-bottom: 0.25rem; +} + +.stats-overview__label { + font-size: 0.875rem; + color: #7f8c8d; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +@media (max-width: 768px) { + .stats-overview { + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + } + + .stats-overview__card { + padding: 1rem; + } + + .stats-overview__icon { + font-size: 2rem; + } + + .stats-overview__value { + font-size: 1.5rem; + } +} + +@media (max-width: 480px) { + .stats-overview { + grid-template-columns: 1fr; + } +} diff --git a/assets/styles/components/_table.scss b/assets/styles/components/_table.scss deleted file mode 100644 index 65093f3..0000000 --- a/assets/styles/components/_table.scss +++ /dev/null @@ -1,34 +0,0 @@ -.table { - width: 100%; - border-collapse: collapse; - margin-top: $spacing-lg; - box-shadow: $shadow-md; - - &__header { - background-color: $color-background; - font-weight: bold; - color: $color-text-secondary; - } - - &__cell { - padding: $spacing-sm $spacing-md; - text-align: left; - border-bottom: 1px solid $color-border; - - &--metric { - font-weight: bold; - text-align: center; - } - - &--best-value { - font-weight: bold; - color: $color-success; - } - } - - &__row { - &:hover { - background-color: $color-background-light; - } - } -} diff --git a/compose.override.yaml b/compose.override.yaml new file mode 100644 index 0000000..187de4f --- /dev/null +++ b/compose.override.yaml @@ -0,0 +1,7 @@ + +services: +###> symfony/mercure-bundle ### + mercure: + ports: + - "80" +###< symfony/mercure-bundle ### diff --git a/composer.json b/composer.json index d72085a..595e1bc 100644 --- a/composer.json +++ b/composer.json @@ -2,6 +2,7 @@ "type": "project", "license": "proprietary", "name": "jblairy/php_benchmark", + "description": "A PHP benchmark tool", "authors": [ { "name": "Julien Blairy" @@ -13,27 +14,28 @@ "php": ">=8.4", "ext-ctype": "*", "ext-iconv": "*", - "doctrine/dbal": "^3", - "doctrine/doctrine-bundle": "^2.15", - "doctrine/doctrine-migrations-bundle": "^3.4", - "doctrine/orm": "^3.5", - "phpdocumentor/reflection-docblock": "^5.6", - "phpstan/phpdoc-parser": "^2.1", - "spatie/async": "*", + "doctrine/dbal": "^3.10.3", + "doctrine/doctrine-bundle": "^2.18", + "doctrine/doctrine-migrations-bundle": "^3.5", + "doctrine/orm": "^3.5.3", + "phpdocumentor/reflection-docblock": "^5.6.3", + "phpstan/phpdoc-parser": "^2.3", + "spatie/async": "^1.8.0", "symfony/asset": "7.3.*", "symfony/asset-mapper": "7.3.*", "symfony/console": "7.3.*", "symfony/doctrine-messenger": "7.3.*", "symfony/dotenv": "7.3.*", "symfony/expression-language": "7.3.*", - "symfony/flex": "^2", + "symfony/flex": "^2.9", "symfony/form": "7.3.*", "symfony/framework-bundle": "7.3.*", "symfony/http-client": "7.3.*", "symfony/intl": "7.3.*", "symfony/mailer": "7.3.*", + "symfony/mercure-bundle": "^0.3.9", "symfony/mime": "7.3.*", - "symfony/monolog-bundle": "^3.0", + "symfony/monolog-bundle": "^3.10", "symfony/notifier": "7.3.*", "symfony/process": "7.3.*", "symfony/property-access": "7.3.*", @@ -41,24 +43,26 @@ "symfony/runtime": "7.3.*", "symfony/security-bundle": "7.3.*", "symfony/serializer": "7.3.*", - "symfony/stimulus-bundle": "^2.30", + "symfony/stimulus-bundle": "^2.31", "symfony/string": "7.3.*", "symfony/translation": "7.3.*", "symfony/twig-bundle": "7.3.*", - "symfony/ux-chartjs": "^2.30", - "symfony/ux-turbo": "^2.27", + "symfony/ux-chartjs": "^2.31", + "symfony/ux-live-component": "^2.31", + "symfony/ux-turbo": "^2.31", "symfony/validator": "7.3.*", "symfony/web-link": "7.3.*", "symfony/yaml": "7.3.*", "symfonycasts/sass-bundle": "^0.8.3", - "twig/extra-bundle": "^2.12|^3.0", - "twig/twig": "^2.12|^3.0" + "twig/extra-bundle": "^2.12|^3.22", + "twig/twig": "^2.12|^3.22" }, "config": { "allow-plugins": { "php-http/discovery": true, "symfony/flex": true, - "symfony/runtime": true + "symfony/runtime": true, + "infection/extension-installer": true }, "bump-after-update": true, "sort-packages": true @@ -106,20 +110,23 @@ } }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.82.2", - "phparkitect/phparkitect": "*", + "doctrine/doctrine-fixtures-bundle": "^4.3", + "friendsofphp/php-cs-fixer": "^3.89.1", + "infection/infection": "^0.29.14", + "phparkitect/phparkitect": ">=0.6", "phpmd/phpmd": "^2.15", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-deprecation-rules": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpstan/phpstan-symfony": "^2.0", + "phpstan/phpstan": "^2.1.31", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpstan/phpstan-strict-rules": "^2.0.7", + "phpstan/phpstan-symfony": "^2.0.8", "phpstan/phpstan-webmozart-assert": "^2.0", - "phpunit/phpunit": "^12.2", + "phpunit/phpunit": "^12.4.2", + "rector/rector": "^2.2.7", "symfony/browser-kit": "7.3.*", "symfony/css-selector": "7.3.*", "symfony/debug-bundle": "7.3.*", - "symfony/maker-bundle": "^1.0", + "symfony/maker-bundle": "^1.64", "symfony/stopwatch": "7.3.*", "symfony/web-profiler-bundle": "7.3.*" } diff --git a/composer.lock b/composer.lock index e0df46c..2890708 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "57e85c0006773729c0de57a5f97ed581", + "content-hash": "fad53bfd7e29e1e0d173cb624e3f09d8", "packages": [ { "name": "composer/semver", - "version": "3.4.3", + "version": "3.4.4", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12" + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "shasum": "" }, "require": { @@ -69,7 +69,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.3" + "source": "https://github.com/composer/semver/tree/3.4.4" }, "funding": [ { @@ -79,119 +79,22 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-09-19T14:15:21+00:00" - }, - { - "name": "doctrine/cache", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/doctrine/cache.git", - "reference": "1ca8f21980e770095a31456042471a57bc4c68fb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/cache/zipball/1ca8f21980e770095a31456042471a57bc4c68fb", - "reference": "1ca8f21980e770095a31456042471a57bc4c68fb", - "shasum": "" - }, - "require": { - "php": "~7.1 || ^8.0" - }, - "conflict": { - "doctrine/common": ">2.2,<2.4" - }, - "require-dev": { - "cache/integration-tests": "dev-master", - "doctrine/coding-standard": "^9", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psr/cache": "^1.0 || ^2.0 || ^3.0", - "symfony/cache": "^4.4 || ^5.4 || ^6", - "symfony/var-exporter": "^4.4 || ^5.4 || ^6" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Guilherme Blanco", - "email": "guilhermeblanco@gmail.com" - }, - { - "name": "Roman Borschel", - "email": "roman@code-factory.org" - }, - { - "name": "Benjamin Eberlei", - "email": "kontakt@beberlei.de" - }, - { - "name": "Jonathan Wage", - "email": "jonwage@gmail.com" - }, - { - "name": "Johannes Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "PHP Doctrine Cache library is a popular cache implementation that supports many different drivers such as redis, memcache, apc, mongodb and others.", - "homepage": "https://www.doctrine-project.org/projects/cache.html", - "keywords": [ - "abstraction", - "apcu", - "cache", - "caching", - "couchdb", - "memcached", - "php", - "redis", - "xcache" - ], - "support": { - "issues": "https://github.com/doctrine/cache/issues", - "source": "https://github.com/doctrine/cache/tree/2.2.0" - }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcache", - "type": "tidelift" } ], - "time": "2022-05-20T20:07:39+00:00" + "time": "2025-08-20T19:15:30+00:00" }, { "name": "doctrine/collections", - "version": "2.3.0", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/doctrine/collections.git", - "reference": "2eb07e5953eed811ce1b309a7478a3b236f2273d" + "reference": "9acfeea2e8666536edff3d77c531261c63680160" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/collections/zipball/2eb07e5953eed811ce1b309a7478a3b236f2273d", - "reference": "2eb07e5953eed811ce1b309a7478a3b236f2273d", + "url": "https://api.github.com/repos/doctrine/collections/zipball/9acfeea2e8666536edff3d77c531261c63680160", + "reference": "9acfeea2e8666536edff3d77c531261c63680160", "shasum": "" }, "require": { @@ -200,11 +103,11 @@ "symfony/polyfill-php84": "^1.30" }, "require-dev": { - "doctrine/coding-standard": "^12", + "doctrine/coding-standard": "^14", "ext-json": "*", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^10.5" + "phpstan/phpstan": "^2.1.30", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpunit/phpunit": "^10.5.58 || ^11.5.42 || ^12.4" }, "type": "library", "autoload": { @@ -248,7 +151,7 @@ ], "support": { "issues": "https://github.com/doctrine/collections/issues", - "source": "https://github.com/doctrine/collections/tree/2.3.0" + "source": "https://github.com/doctrine/collections/tree/2.4.0" }, "funding": [ { @@ -264,40 +167,43 @@ "type": "tidelift" } ], - "time": "2025-03-22T10:17:19+00:00" + "time": "2025-10-25T09:18:13+00:00" }, { "name": "doctrine/dbal", - "version": "3.9.5", + "version": "3.10.3", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "4a4e2eed3134036ee36a147ee0dac037dfa17868" + "reference": "65edaca19a752730f290ec2fb89d593cb40afb43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/4a4e2eed3134036ee36a147ee0dac037dfa17868", - "reference": "4a4e2eed3134036ee36a147ee0dac037dfa17868", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/65edaca19a752730f290ec2fb89d593cb40afb43", + "reference": "65edaca19a752730f290ec2fb89d593cb40afb43", "shasum": "" }, "require": { "composer-runtime-api": "^2", - "doctrine/cache": "^1.11|^2.0", "doctrine/deprecations": "^0.5.3|^1", "doctrine/event-manager": "^1|^2", "php": "^7.4 || ^8.0", "psr/cache": "^1|^2|^3", "psr/log": "^1|^2|^3" }, + "conflict": { + "doctrine/cache": "< 1.11" + }, "require-dev": { - "doctrine/coding-standard": "13.0.0", + "doctrine/cache": "^1.11|^2.0", + "doctrine/coding-standard": "14.0.0", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2023.1", - "phpstan/phpstan": "2.1.17", + "phpstan/phpstan": "2.1.30", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "9.6.23", - "slevomat/coding-standard": "8.16.2", - "squizlabs/php_codesniffer": "3.13.1", + "phpunit/phpunit": "9.6.29", + "slevomat/coding-standard": "8.24.0", + "squizlabs/php_codesniffer": "4.0.0", "symfony/cache": "^5.4|^6.0|^7.0", "symfony/console": "^4.4|^5.4|^6.0|^7.0" }, @@ -359,7 +265,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/3.9.5" + "source": "https://github.com/doctrine/dbal/tree/3.10.3" }, "funding": [ { @@ -375,7 +281,7 @@ "type": "tidelift" } ], - "time": "2025-06-15T22:40:05+00:00" + "time": "2025-10-09T09:05:12+00:00" }, { "name": "doctrine/deprecations", @@ -427,20 +333,21 @@ }, { "name": "doctrine/doctrine-bundle", - "version": "2.15.0", + "version": "2.18.0", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "d88294521a1bca943240adca65fa19ca8a7288c6" + "reference": "cd5d4da6a5f7cf3d8708e17211234657b5eb4e95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/d88294521a1bca943240adca65fa19ca8a7288c6", - "reference": "d88294521a1bca943240adca65fa19ca8a7288c6", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/cd5d4da6a5f7cf3d8708e17211234657b5eb4e95", + "reference": "cd5d4da6a5f7cf3d8708e17211234657b5eb4e95", "shasum": "" }, "require": { "doctrine/dbal": "^3.7.0 || ^4.0", + "doctrine/deprecations": "^1.0", "doctrine/persistence": "^3.1 || ^4", "doctrine/sql-formatter": "^1.0.1", "php": "^8.1", @@ -448,7 +355,6 @@ "symfony/config": "^6.4 || ^7.0", "symfony/console": "^6.4 || ^7.0", "symfony/dependency-injection": "^6.4 || ^7.0", - "symfony/deprecation-contracts": "^2.1 || ^3", "symfony/doctrine-bridge": "^6.4.3 || ^7.0.3", "symfony/framework-bundle": "^6.4 || ^7.0", "symfony/service-contracts": "^2.5 || ^3" @@ -463,18 +369,17 @@ "require-dev": { "doctrine/annotations": "^1 || ^2", "doctrine/cache": "^1.11 || ^2.0", - "doctrine/coding-standard": "^13", - "doctrine/deprecations": "^1.0", + "doctrine/coding-standard": "^14", "doctrine/orm": "^2.17 || ^3.1", "friendsofphp/proxy-manager-lts": "^1.0", "phpstan/phpstan": "2.1.1", "phpstan/phpstan-phpunit": "2.0.3", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "^9.6.22", + "phpunit/phpunit": "^10.5.53 || ^12.3.10", "psr/log": "^1.1.4 || ^2.0 || ^3.0", "symfony/doctrine-messenger": "^6.4 || ^7.0", + "symfony/expression-language": "^6.4 || ^7.0", "symfony/messenger": "^6.4 || ^7.0", - "symfony/phpunit-bridge": "^7.2", "symfony/property-info": "^6.4 || ^7.0", "symfony/security-bundle": "^6.4 || ^7.0", "symfony/stopwatch": "^6.4 || ^7.0", @@ -484,7 +389,7 @@ "symfony/var-exporter": "^6.4.1 || ^7.0.1", "symfony/web-profiler-bundle": "^6.4 || ^7.0", "symfony/yaml": "^6.4 || ^7.0", - "twig/twig": "^2.13 || ^3.0.4" + "twig/twig": "^2.14.7 || ^3.0.4" }, "suggest": { "doctrine/orm": "The Doctrine ORM integration is optional in the bundle.", @@ -529,7 +434,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineBundle/issues", - "source": "https://github.com/doctrine/DoctrineBundle/tree/2.15.0" + "source": "https://github.com/doctrine/DoctrineBundle/tree/2.18.0" }, "funding": [ { @@ -545,24 +450,24 @@ "type": "tidelift" } ], - "time": "2025-06-16T19:53:58+00:00" + "time": "2025-10-11T04:43:27+00:00" }, { "name": "doctrine/doctrine-migrations-bundle", - "version": "3.4.2", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineMigrationsBundle.git", - "reference": "5a6ac7120c2924c4c070a869d08b11ccf9e277b9" + "reference": "71c81279ca0e907c3edc718418b93fd63074856c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/5a6ac7120c2924c4c070a869d08b11ccf9e277b9", - "reference": "5a6ac7120c2924c4c070a869d08b11ccf9e277b9", + "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/71c81279ca0e907c3edc718418b93fd63074856c", + "reference": "71c81279ca0e907c3edc718418b93fd63074856c", "shasum": "" }, "require": { - "doctrine/doctrine-bundle": "^2.4", + "doctrine/doctrine-bundle": "^2.4 || ^3.0", "doctrine/migrations": "^3.2", "php": "^7.2 || ^8.0", "symfony/deprecation-contracts": "^2.1 || ^3", @@ -570,7 +475,7 @@ }, "require-dev": { "composer/semver": "^3.0", - "doctrine/coding-standard": "^12", + "doctrine/coding-standard": "^12 || ^14", "doctrine/orm": "^2.6 || ^3", "phpstan/phpstan": "^1.4 || ^2", "phpstan/phpstan-deprecation-rules": "^1 || ^2", @@ -614,7 +519,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineMigrationsBundle/issues", - "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/3.4.2" + "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/3.5.0" }, "funding": [ { @@ -630,7 +535,7 @@ "type": "tidelift" } ], - "time": "2025-03-11T17:36:26+00:00" + "time": "2025-10-12T17:06:40+00:00" }, { "name": "doctrine/event-manager", @@ -725,33 +630,32 @@ }, { "name": "doctrine/inflector", - "version": "2.0.10", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/inflector.git", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc" + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^11.0", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.3", - "phpunit/phpunit": "^8.5 || ^9.5", - "vimeo/psalm": "^4.25 || ^5.4" + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" }, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Inflector\\": "lib/Doctrine/Inflector" + "Doctrine\\Inflector\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -796,7 +700,7 @@ ], "support": { "issues": "https://github.com/doctrine/inflector/issues", - "source": "https://github.com/doctrine/inflector/tree/2.0.10" + "source": "https://github.com/doctrine/inflector/tree/2.1.0" }, "funding": [ { @@ -812,7 +716,7 @@ "type": "tidelift" } ], - "time": "2024-02-18T20:23:39+00:00" + "time": "2025-08-10T19:31:58+00:00" }, { "name": "doctrine/instantiator", @@ -963,16 +867,16 @@ }, { "name": "doctrine/migrations", - "version": "3.9.1", + "version": "3.9.4", "source": { "type": "git", "url": "https://github.com/doctrine/migrations.git", - "reference": "0f1e0c960ac29866d648a4f50142a74fe1cb6999" + "reference": "1b88fcb812f2cd6e77c83d16db60e3cf1e35c66c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/migrations/zipball/0f1e0c960ac29866d648a4f50142a74fe1cb6999", - "reference": "0f1e0c960ac29866d648a4f50142a74fe1cb6999", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/1b88fcb812f2cd6e77c83d16db60e3cf1e35c66c", + "reference": "1b88fcb812f2cd6e77c83d16db60e3cf1e35c66c", "shasum": "" }, "require": { @@ -990,18 +894,18 @@ "doctrine/orm": "<2.12 || >=4" }, "require-dev": { - "doctrine/coding-standard": "^12", + "doctrine/coding-standard": "^13", "doctrine/orm": "^2.13 || ^3", "doctrine/persistence": "^2 || ^3 || ^4", "doctrine/sql-formatter": "^1.0", "ext-pdo_sqlite": "*", "fig/log-test": "^1", - "phpstan/phpstan": "^1.10", - "phpstan/phpstan-deprecation-rules": "^1.1", - "phpstan/phpstan-phpunit": "^1.3", - "phpstan/phpstan-strict-rules": "^1.4", - "phpstan/phpstan-symfony": "^1.3", - "phpunit/phpunit": "^10.3", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpstan/phpstan-symfony": "^2", + "phpunit/phpunit": "^10.3 || ^11.0 || ^12.0", "symfony/cache": "^5.4 || ^6.0 || ^7.0", "symfony/process": "^5.4 || ^6.0 || ^7.0", "symfony/yaml": "^5.4 || ^6.0 || ^7.0" @@ -1046,7 +950,7 @@ ], "support": { "issues": "https://github.com/doctrine/migrations/issues", - "source": "https://github.com/doctrine/migrations/tree/3.9.1" + "source": "https://github.com/doctrine/migrations/tree/3.9.4" }, "funding": [ { @@ -1062,20 +966,20 @@ "type": "tidelift" } ], - "time": "2025-06-27T07:19:23+00:00" + "time": "2025-08-19T06:41:07+00:00" }, { "name": "doctrine/orm", - "version": "3.5.0", + "version": "3.5.3", "source": { "type": "git", "url": "https://github.com/doctrine/orm.git", - "reference": "6deec3655ba3e8f15280aac11e264225854d2369" + "reference": "1220edf9535303feb6dbfcf171beeef842fc9e1c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/6deec3655ba3e8f15280aac11e264225854d2369", - "reference": "6deec3655ba3e8f15280aac11e264225854d2369", + "url": "https://api.github.com/repos/doctrine/orm/zipball/1220edf9535303feb6dbfcf171beeef842fc9e1c", + "reference": "1220edf9535303feb6dbfcf171beeef842fc9e1c", "shasum": "" }, "require": { @@ -1095,15 +999,14 @@ "symfony/var-exporter": "^6.3.9 || ^7.0" }, "require-dev": { - "doctrine/coding-standard": "^13.0", + "doctrine/coding-standard": "^14.0", "phpbench/phpbench": "^1.0", "phpdocumentor/guides-cli": "^1.4", "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "2.0.3", + "phpstan/phpstan": "2.1.22", "phpstan/phpstan-deprecation-rules": "^2", - "phpunit/phpunit": "^10.4.0", + "phpunit/phpunit": "^10.5.0 || ^11.5", "psr/log": "^1 || ^2 || ^3", - "squizlabs/php_codesniffer": "3.12.0", "symfony/cache": "^5.4 || ^6.2 || ^7.0" }, "suggest": { @@ -1150,22 +1053,22 @@ ], "support": { "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.5.0" + "source": "https://github.com/doctrine/orm/tree/3.5.3" }, - "time": "2025-07-01T17:40:53+00:00" + "time": "2025-10-27T22:06:52+00:00" }, { "name": "doctrine/persistence", - "version": "4.0.0", + "version": "4.1.1", "source": { "type": "git", "url": "https://github.com/doctrine/persistence.git", - "reference": "45004aca79189474f113cbe3a53847c2115a55fa" + "reference": "b9c49ad3558bb77ef973f4e173f2e9c2eca9be09" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/persistence/zipball/45004aca79189474f113cbe3a53847c2115a55fa", - "reference": "45004aca79189474f113cbe3a53847c2115a55fa", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/b9c49ad3558bb77ef973f4e173f2e9c2eca9be09", + "reference": "b9c49ad3558bb77ef973f4e173f2e9c2eca9be09", "shasum": "" }, "require": { @@ -1173,16 +1076,14 @@ "php": "^8.1", "psr/cache": "^1.0 || ^2.0 || ^3.0" }, - "conflict": { - "doctrine/common": "<2.10" - }, "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "1.12.7", - "phpstan/phpstan-phpunit": "^1", - "phpstan/phpstan-strict-rules": "^1.1", - "phpunit/phpunit": "^9.6", - "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0" + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.58 || ^12", + "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0", + "symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0" }, "type": "library", "autoload": { @@ -1231,7 +1132,7 @@ ], "support": { "issues": "https://github.com/doctrine/persistence/issues", - "source": "https://github.com/doctrine/persistence/tree/4.0.0" + "source": "https://github.com/doctrine/persistence/tree/4.1.1" }, "funding": [ { @@ -1247,30 +1148,30 @@ "type": "tidelift" } ], - "time": "2024-11-01T21:49:07+00:00" + "time": "2025-10-16T20:13:18+00:00" }, { "name": "doctrine/sql-formatter", - "version": "1.5.2", + "version": "1.5.3", "source": { "type": "git", "url": "https://github.com/doctrine/sql-formatter.git", - "reference": "d6d00aba6fd2957fe5216fe2b7673e9985db20c8" + "reference": "a8af23a8e9d622505baa2997465782cbe8bb7fc7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/d6d00aba6fd2957fe5216fe2b7673e9985db20c8", - "reference": "d6d00aba6fd2957fe5216fe2b7673e9985db20c8", + "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/a8af23a8e9d622505baa2997465782cbe8bb7fc7", + "reference": "a8af23a8e9d622505baa2997465782cbe8bb7fc7", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^12", - "ergebnis/phpunit-slow-test-detector": "^2.14", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.5" + "doctrine/coding-standard": "^14", + "ergebnis/phpunit-slow-test-detector": "^2.20", + "phpstan/phpstan": "^2.1.31", + "phpunit/phpunit": "^10.5.58" }, "bin": [ "bin/sql-formatter" @@ -1300,9 +1201,9 @@ ], "support": { "issues": "https://github.com/doctrine/sql-formatter/issues", - "source": "https://github.com/doctrine/sql-formatter/tree/1.5.2" + "source": "https://github.com/doctrine/sql-formatter/tree/1.5.3" }, - "time": "2025-01-24T11:45:48+00:00" + "time": "2025-10-26T09:35:14+00:00" }, { "name": "egulias/email-validator", @@ -1373,32 +1274,32 @@ }, { "name": "laravel/serializable-closure", - "version": "v1.3.7", + "version": "v2.0.6", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "4f48ade902b94323ca3be7646db16209ec76be3d" + "reference": "038ce42edee619599a1debb7e81d7b3759492819" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/4f48ade902b94323ca3be7646db16209ec76be3d", - "reference": "4f48ade902b94323ca3be7646db16209ec76be3d", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/038ce42edee619599a1debb7e81d7b3759492819", + "reference": "038ce42edee619599a1debb7e81d7b3759492819", "shasum": "" }, "require": { - "php": "^7.3|^8.0" + "php": "^8.1" }, "require-dev": { - "illuminate/support": "^8.0|^9.0|^10.0|^11.0", - "nesbot/carbon": "^2.61|^3.0", - "pestphp/pest": "^1.21.3", - "phpstan/phpstan": "^1.8.2", - "symfony/var-dumper": "^5.4.11|^6.2.0|^7.0.0" + "illuminate/support": "^10.0|^11.0|^12.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.x-dev" + "dev-master": "2.x-dev" } }, "autoload": { @@ -1430,7 +1331,80 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2024-11-14T18:34:49+00:00" + "time": "2025-10-09T13:42:30+00:00" + }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" }, { "name": "monolog/monolog", @@ -1590,16 +1564,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.2", + "version": "5.6.3", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62" + "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/92dde6a5919e34835c506ac8c523ef095a95ed62", - "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/94f8051919d1b0369a6bcc7931d679a511c03fe9", + "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9", "shasum": "" }, "require": { @@ -1648,9 +1622,9 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.2" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.3" }, - "time": "2025-04-13T19:20:35+00:00" + "time": "2025-08-01T19:43:32+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -1712,16 +1686,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.1.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68" + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", "shasum": "" }, "require": { @@ -1753,9 +1727,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.1.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0" }, - "time": "2025-02-19T13:28:12+00:00" + "time": "2025-08-30T15:50:23+00:00" }, { "name": "psr/cache", @@ -2065,20 +2039,20 @@ }, { "name": "spatie/async", - "version": "1.7.0", + "version": "1.8.0", "source": { "type": "git", "url": "https://github.com/spatie/async.git", - "reference": "318fed03bdaef5aa090cc08ee378e29c946ca38e" + "reference": "22d12b039ff00138957673b7da3e2dfaf6dabfc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/async/zipball/318fed03bdaef5aa090cc08ee378e29c946ca38e", - "reference": "318fed03bdaef5aa090cc08ee378e29c946ca38e", + "url": "https://api.github.com/repos/spatie/async/zipball/22d12b039ff00138957673b7da3e2dfaf6dabfc8", + "reference": "22d12b039ff00138957673b7da3e2dfaf6dabfc8", "shasum": "" }, "require": { - "laravel/serializable-closure": "^1.3.7", + "laravel/serializable-closure": "^1.3.7|^2.0.0", "php": "^8.3", "symfony/process": "^7.2" }, @@ -2120,7 +2094,7 @@ ], "support": { "issues": "https://github.com/spatie/async/issues", - "source": "https://github.com/spatie/async/tree/1.7.0" + "source": "https://github.com/spatie/async/tree/1.8.0" }, "funding": [ { @@ -2128,7 +2102,7 @@ "type": "github" } ], - "time": "2025-01-31T14:59:30+00:00" + "time": "2025-08-05T11:58:48+00:00" }, { "name": "symfony/asset", @@ -2201,16 +2175,16 @@ }, { "name": "symfony/asset-mapper", - "version": "v7.3.0", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/asset-mapper.git", - "reference": "6516f38868b75c4902ea72a9fa44967628375ae7" + "reference": "3e731c87d5e2742eac5a8b8536cb4f21a4398d34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/asset-mapper/zipball/6516f38868b75c4902ea72a9fa44967628375ae7", - "reference": "6516f38868b75c4902ea72a9fa44967628375ae7", + "url": "https://api.github.com/repos/symfony/asset-mapper/zipball/3e731c87d5e2742eac5a8b8536cb4f21a4398d34", + "reference": "3e731c87d5e2742eac5a8b8536cb4f21a4398d34", "shasum": "" }, "require": { @@ -2261,7 +2235,7 @@ "description": "Maps directories of assets & makes them available in a public directory with versioned filenames.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/asset-mapper/tree/v7.3.0" + "source": "https://github.com/symfony/asset-mapper/tree/v7.3.5" }, "funding": [ { @@ -2272,25 +2246,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-24T14:05:12+00:00" + "time": "2025-10-15T18:45:57+00:00" }, { "name": "symfony/cache", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "a7c6caa9d6113cebfb3020b427bcb021ebfdfc9e" + "reference": "4a55feb59664f49042a0824c0f955e2f4c1412ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/a7c6caa9d6113cebfb3020b427bcb021ebfdfc9e", - "reference": "a7c6caa9d6113cebfb3020b427bcb021ebfdfc9e", + "url": "https://api.github.com/repos/symfony/cache/zipball/4a55feb59664f49042a0824c0f955e2f4c1412ad", + "reference": "4a55feb59664f49042a0824c0f955e2f4c1412ad", "shasum": "" }, "require": { @@ -2359,7 +2337,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.3.1" + "source": "https://github.com/symfony/cache/tree/v7.3.5" }, "funding": [ { @@ -2370,12 +2348,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-10-16T13:55:38+00:00" }, { "name": "symfony/cache-contracts", @@ -2529,16 +2511,16 @@ }, { "name": "symfony/config", - "version": "v7.3.0", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "ba62ae565f1327c2f6366726312ed828c85853bc" + "reference": "8a09223170046d2cfda3d2e11af01df2c641e961" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/ba62ae565f1327c2f6366726312ed828c85853bc", - "reference": "ba62ae565f1327c2f6366726312ed828c85853bc", + "url": "https://api.github.com/repos/symfony/config/zipball/8a09223170046d2cfda3d2e11af01df2c641e961", + "reference": "8a09223170046d2cfda3d2e11af01df2c641e961", "shasum": "" }, "require": { @@ -2584,7 +2566,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.3.0" + "source": "https://github.com/symfony/config/tree/v7.3.4" }, "funding": [ { @@ -2595,25 +2577,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-15T09:04:05+00:00" + "time": "2025-09-22T12:46:16+00:00" }, { "name": "symfony/console", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101" + "reference": "cdb80fa5869653c83cfe1a9084a673b6daf57ea7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9e27aecde8f506ba0fd1d9989620c04a87697101", - "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101", + "url": "https://api.github.com/repos/symfony/console/zipball/cdb80fa5869653c83cfe1a9084a673b6daf57ea7", + "reference": "cdb80fa5869653c83cfe1a9084a673b6daf57ea7", "shasum": "" }, "require": { @@ -2678,7 +2664,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.1" + "source": "https://github.com/symfony/console/tree/v7.3.5" }, "funding": [ { @@ -2689,25 +2675,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-10-14T15:46:26+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.3.1", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "8656c4848b48784c4bb8c4ae50d2b43f832cead8" + "reference": "82119812ab0bf3425c1234d413efd1b19bb92ae4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/8656c4848b48784c4bb8c4ae50d2b43f832cead8", - "reference": "8656c4848b48784c4bb8c4ae50d2b43f832cead8", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/82119812ab0bf3425c1234d413efd1b19bb92ae4", + "reference": "82119812ab0bf3425c1234d413efd1b19bb92ae4", "shasum": "" }, "require": { @@ -2758,7 +2748,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.3.1" + "source": "https://github.com/symfony/dependency-injection/tree/v7.3.4" }, "funding": [ { @@ -2769,12 +2759,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-24T04:04:43+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/deprecation-contracts", @@ -2845,16 +2839,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "6c0acb248c46452ae2c15752dc71e72f3335403f" + "reference": "e7d308bd44ff8673a259e2727d13af6a93a5d83e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/6c0acb248c46452ae2c15752dc71e72f3335403f", - "reference": "6c0acb248c46452ae2c15752dc71e72f3335403f", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/e7d308bd44ff8673a259e2727d13af6a93a5d83e", + "reference": "e7d308bd44ff8673a259e2727d13af6a93a5d83e", "shasum": "" }, "require": { @@ -2903,7 +2897,7 @@ "symfony/security-core": "^6.4|^7.0", "symfony/stopwatch": "^6.4|^7.0", "symfony/translation": "^6.4|^7.0", - "symfony/type-info": "^7.1", + "symfony/type-info": "^7.1.8", "symfony/uid": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0" @@ -2934,7 +2928,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v7.3.1" + "source": "https://github.com/symfony/doctrine-bridge/tree/v7.3.5" }, "funding": [ { @@ -2945,25 +2939,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-26T13:02:59+00:00" + "time": "2025-09-27T09:00:46+00:00" }, { "name": "symfony/doctrine-messenger", - "version": "v7.3.0", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-messenger.git", - "reference": "099d9cd03f889c31c90d406fed07f25dc3732487" + "reference": "064159484ab330590b7b477f6c8835812f2e340f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/099d9cd03f889c31c90d406fed07f25dc3732487", - "reference": "099d9cd03f889c31c90d406fed07f25dc3732487", + "url": "https://api.github.com/repos/symfony/doctrine-messenger/zipball/064159484ab330590b7b477f6c8835812f2e340f", + "reference": "064159484ab330590b7b477f6c8835812f2e340f", "shasum": "" }, "require": { @@ -3006,7 +3004,7 @@ "description": "Symfony Doctrine Messenger Bridge", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-messenger/tree/v7.3.0" + "source": "https://github.com/symfony/doctrine-messenger/tree/v7.3.4" }, "funding": [ { @@ -3017,25 +3015,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-26T11:30:13+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/dotenv", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "28347a897771d0c28e99b75166dd2689099f3045" + "reference": "2192790a11f9e22cbcf9dc705a3ff22a5503923a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/28347a897771d0c28e99b75166dd2689099f3045", - "reference": "28347a897771d0c28e99b75166dd2689099f3045", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/2192790a11f9e22cbcf9dc705a3ff22a5503923a", + "reference": "2192790a11f9e22cbcf9dc705a3ff22a5503923a", "shasum": "" }, "require": { @@ -3080,7 +3082,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v7.3.0" + "source": "https://github.com/symfony/dotenv/tree/v7.3.2" }, "funding": [ { @@ -3091,25 +3093,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-27T11:18:42+00:00" + "time": "2025-07-10T08:29:33+00:00" }, { "name": "symfony/error-handler", - "version": "v7.3.1", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235" + "reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/35b55b166f6752d6aaf21aa042fc5ed280fce235", - "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/99f81bc944ab8e5dae4f21b4ca9972698bbad0e4", + "reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4", "shasum": "" }, "require": { @@ -3157,7 +3163,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.3.1" + "source": "https://github.com/symfony/error-handler/tree/v7.3.4" }, "funding": [ { @@ -3168,25 +3174,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-13T07:48:40+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.3.0", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "497f73ac996a598c92409b44ac43b6690c4f666d" + "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/497f73ac996a598c92409b44ac43b6690c4f666d", - "reference": "497f73ac996a598c92409b44ac43b6690c4f666d", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b7dc69e71de420ac04bc9ab830cf3ffebba48191", + "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191", "shasum": "" }, "require": { @@ -3237,7 +3247,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.3" }, "funding": [ { @@ -3248,12 +3258,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-22T09:11:45+00:00" + "time": "2025-08-13T11:49:31+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -3333,16 +3347,16 @@ }, { "name": "symfony/expression-language", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/expression-language.git", - "reference": "26f4884a455e755e630a5fc372df124a3578da2e" + "reference": "32d2d19c62e58767e6552166c32fb259975d2b23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/expression-language/zipball/26f4884a455e755e630a5fc372df124a3578da2e", - "reference": "26f4884a455e755e630a5fc372df124a3578da2e", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/32d2d19c62e58767e6552166c32fb259975d2b23", + "reference": "32d2d19c62e58767e6552166c32fb259975d2b23", "shasum": "" }, "require": { @@ -3377,7 +3391,7 @@ "description": "Provides an engine that can compile and evaluate expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/expression-language/tree/v7.3.0" + "source": "https://github.com/symfony/expression-language/tree/v7.3.2" }, "funding": [ { @@ -3388,25 +3402,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-10-15T11:52:45+00:00" + "time": "2025-07-10T08:29:33+00:00" }, { "name": "symfony/filesystem", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb" + "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/b8dce482de9d7c9fe2891155035a7248ab5c7fdb", - "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/edcbb768a186b5c3f25d0643159a787d3e63b7fd", + "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd", "shasum": "" }, "require": { @@ -3443,7 +3461,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.3.0" + "source": "https://github.com/symfony/filesystem/tree/v7.3.2" }, "funding": [ { @@ -3454,25 +3472,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-10-25T15:15:23+00:00" + "time": "2025-07-07T08:17:47+00:00" }, { "name": "symfony/finder", - "version": "v7.3.0", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d" + "reference": "9f696d2f1e340484b4683f7853b273abff94421f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ec2344cf77a48253bbca6939aa3d2477773ea63d", - "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d", + "url": "https://api.github.com/repos/symfony/finder/zipball/9f696d2f1e340484b4683f7853b273abff94421f", + "reference": "9f696d2f1e340484b4683f7853b273abff94421f", "shasum": "" }, "require": { @@ -3507,7 +3529,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.0" + "source": "https://github.com/symfony/finder/tree/v7.3.5" }, "funding": [ { @@ -3518,40 +3540,44 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-30T19:00:26+00:00" + "time": "2025-10-15T18:45:57+00:00" }, { "name": "symfony/flex", - "version": "v2.8.1", + "version": "v2.9.0", "source": { "type": "git", "url": "https://github.com/symfony/flex.git", - "reference": "423c36e369361003dc31ef11c5f15fb589e52c01" + "reference": "94b37978c9982dc41c5b6a4147892d2d3d1b9ce6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/flex/zipball/423c36e369361003dc31ef11c5f15fb589e52c01", - "reference": "423c36e369361003dc31ef11c5f15fb589e52c01", + "url": "https://api.github.com/repos/symfony/flex/zipball/94b37978c9982dc41c5b6a4147892d2d3d1b9ce6", + "reference": "94b37978c9982dc41c5b6a4147892d2d3d1b9ce6", "shasum": "" }, "require": { "composer-plugin-api": "^2.1", - "php": ">=8.0" + "php": ">=8.1" }, "conflict": { "composer/semver": "<1.7.2" }, "require-dev": { "composer/composer": "^2.1", - "symfony/dotenv": "^5.4|^6.0", - "symfony/filesystem": "^5.4|^6.0", - "symfony/phpunit-bridge": "^5.4|^6.0", - "symfony/process": "^5.4|^6.0" + "symfony/dotenv": "^6.4|^7.4|^8.0", + "symfony/filesystem": "^6.4|^7.4|^8.0", + "symfony/phpunit-bridge": "^6.4|^7.4|^8.0", + "symfony/process": "^6.4|^7.4|^8.0" }, "type": "composer-plugin", "extra": { @@ -3575,7 +3601,7 @@ "description": "Composer plugin for Symfony", "support": { "issues": "https://github.com/symfony/flex/issues", - "source": "https://github.com/symfony/flex/tree/v2.8.1" + "source": "https://github.com/symfony/flex/tree/v2.9.0" }, "funding": [ { @@ -3586,25 +3612,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-07-05T07:45:19+00:00" + "time": "2025-10-31T15:22:50+00:00" }, { "name": "symfony/form", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/form.git", - "reference": "e06b02dd21b33b0cd7bb942c7e446ef7b22a2a5a" + "reference": "c8032766d5b198865d00b7d4471fc787a0a7f5e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/form/zipball/e06b02dd21b33b0cd7bb942c7e446ef7b22a2a5a", - "reference": "e06b02dd21b33b0cd7bb942c7e446ef7b22a2a5a", + "url": "https://api.github.com/repos/symfony/form/zipball/c8032766d5b198865d00b7d4471fc787a0a7f5e4", + "reference": "c8032766d5b198865d00b7d4471fc787a0a7f5e4", "shasum": "" }, "require": { @@ -3672,7 +3702,7 @@ "description": "Allows to easily create, process and reuse HTML forms", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/form/tree/v7.3.1" + "source": "https://github.com/symfony/form/tree/v7.3.5" }, "funding": [ { @@ -3683,25 +3713,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-13T07:48:40+00:00" + "time": "2025-10-10T11:54:12+00:00" }, { "name": "symfony/framework-bundle", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "91905f22f26aa350a33b3b9690bdf94976b0d0ab" + "reference": "ebd42b1fc2652b96d33520195ea0f6e55c36f09d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/91905f22f26aa350a33b3b9690bdf94976b0d0ab", - "reference": "91905f22f26aa350a33b3b9690bdf94976b0d0ab", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/ebd42b1fc2652b96d33520195ea0f6e55c36f09d", + "reference": "ebd42b1fc2652b96d33520195ea0f6e55c36f09d", "shasum": "" }, "require": { @@ -3791,7 +3825,7 @@ "symfony/string": "^6.4|^7.0", "symfony/translation": "^7.3", "symfony/twig-bundle": "^6.4|^7.0", - "symfony/type-info": "^7.1", + "symfony/type-info": "^7.1.8", "symfony/uid": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", "symfony/web-link": "^6.4|^7.0", @@ -3826,7 +3860,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v7.3.1" + "source": "https://github.com/symfony/framework-bundle/tree/v7.3.5" }, "funding": [ { @@ -3837,25 +3871,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-10-16T16:16:53+00:00" }, { "name": "symfony/http-client", - "version": "v7.3.1", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "4403d87a2c16f33345dca93407a8714ee8c05a64" + "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/4403d87a2c16f33345dca93407a8714ee8c05a64", - "reference": "4403d87a2c16f33345dca93407a8714ee8c05a64", + "url": "https://api.github.com/repos/symfony/http-client/zipball/4b62871a01c49457cf2a8e560af7ee8a94b87a62", + "reference": "4b62871a01c49457cf2a8e560af7ee8a94b87a62", "shasum": "" }, "require": { @@ -3863,6 +3901,7 @@ "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", "symfony/service-contracts": "^2.5|^3" }, "conflict": { @@ -3921,7 +3960,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.3.1" + "source": "https://github.com/symfony/http-client/tree/v7.3.4" }, "funding": [ { @@ -3932,12 +3971,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-28T07:58:39+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/http-client-contracts", @@ -4019,16 +4062,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "23dd60256610c86a3414575b70c596e5deff6ed9" + "reference": "ce31218c7cac92eab280762c4375fb70a6f4f897" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/23dd60256610c86a3414575b70c596e5deff6ed9", - "reference": "23dd60256610c86a3414575b70c596e5deff6ed9", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/ce31218c7cac92eab280762c4375fb70a6f4f897", + "reference": "ce31218c7cac92eab280762c4375fb70a6f4f897", "shasum": "" }, "require": { @@ -4078,7 +4121,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.3.1" + "source": "https://github.com/symfony/http-foundation/tree/v7.3.5" }, "funding": [ { @@ -4089,25 +4132,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-23T15:07:14+00:00" + "time": "2025-10-24T21:42:11+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831" + "reference": "24fd3f123532e26025f49f1abefcc01a69ef15ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1644879a66e4aa29c36fe33dfa6c54b450ce1831", - "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/24fd3f123532e26025f49f1abefcc01a69ef15ab", + "reference": "24fd3f123532e26025f49f1abefcc01a69ef15ab", "shasum": "" }, "require": { @@ -4192,7 +4239,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.3.1" + "source": "https://github.com/symfony/http-kernel/tree/v7.3.5" }, "funding": [ { @@ -4203,25 +4250,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-28T08:24:55+00:00" + "time": "2025-10-28T10:19:01+00:00" }, { "name": "symfony/intl", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/intl.git", - "reference": "bd50940329ac1cfc4af0491cc4468f477d967e45" + "reference": "9eccaaa94ac6f9deb3620c9d47a057d965baeabf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/intl/zipball/bd50940329ac1cfc4af0491cc4468f477d967e45", - "reference": "bd50940329ac1cfc4af0491cc4468f477d967e45", + "url": "https://api.github.com/repos/symfony/intl/zipball/9eccaaa94ac6f9deb3620c9d47a057d965baeabf", + "reference": "9eccaaa94ac6f9deb3620c9d47a057d965baeabf", "shasum": "" }, "require": { @@ -4278,7 +4329,7 @@ "localization" ], "support": { - "source": "https://github.com/symfony/intl/tree/v7.3.1" + "source": "https://github.com/symfony/intl/tree/v7.3.5" }, "funding": [ { @@ -4289,25 +4340,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-06T16:10:07+00:00" + "time": "2025-10-01T06:11:17+00:00" }, { "name": "symfony/mailer", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368" + "reference": "fd497c45ba9c10c37864e19466b090dcb60a50ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b5db5105b290bdbea5ab27b89c69effcf1cb3368", - "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368", + "url": "https://api.github.com/repos/symfony/mailer/zipball/fd497c45ba9c10c37864e19466b090dcb60a50ba", + "reference": "fd497c45ba9c10c37864e19466b090dcb60a50ba", "shasum": "" }, "require": { @@ -4358,7 +4413,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.3.1" + "source": "https://github.com/symfony/mailer/tree/v7.3.5" }, "funding": [ { @@ -4369,35 +4424,206 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-10-24T14:27:20+00:00" }, { - "name": "symfony/messenger", - "version": "v7.3.1", + "name": "symfony/mercure", + "version": "v0.6.5", "source": { "type": "git", - "url": "https://github.com/symfony/messenger.git", - "reference": "716c89b86ce58c4946d436d862694971c999d1aa" + "url": "https://github.com/symfony/mercure.git", + "reference": "304cf84609ef645d63adc65fc6250292909a461b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/messenger/zipball/716c89b86ce58c4946d436d862694971c999d1aa", - "reference": "716c89b86ce58c4946d436d862694971c999d1aa", + "url": "https://api.github.com/repos/symfony/mercure/zipball/304cf84609ef645d63adc65fc6250292909a461b", + "reference": "304cf84609ef645d63adc65fc6250292909a461b", "shasum": "" }, "require": { - "php": ">=8.2", - "psr/log": "^1|^2|^3", - "symfony/clock": "^6.4|^7.0", - "symfony/deprecation-contracts": "^2.5|^3" + "php": ">=7.1.3", + "symfony/deprecation-contracts": "^2.0|^3.0|^4.0", + "symfony/http-client": "^4.4|^5.0|^6.0|^7.0", + "symfony/http-foundation": "^4.4|^5.0|^6.0|^7.0", + "symfony/polyfill-php80": "^1.22", + "symfony/web-link": "^4.4|^5.0|^6.0|^7.0" }, - "conflict": { - "symfony/console": "<7.2", + "require-dev": { + "lcobucci/jwt": "^3.4|^4.0|^5.0", + "symfony/event-dispatcher": "^4.4|^5.0|^6.0|^7.0", + "symfony/http-kernel": "^4.4|^5.0|^6.0|^7.0", + "symfony/phpunit-bridge": "^5.2|^6.0|^7.0", + "symfony/stopwatch": "^4.4|^5.0|^6.0|^7.0", + "twig/twig": "^2.0|^3.0|^4.0" + }, + "suggest": { + "symfony/stopwatch": "Integration with the profiler performances" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/dunglas/mercure", + "name": "dunglas/mercure" + }, + "branch-alias": { + "dev-main": "0.6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Mercure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Mercure Component", + "homepage": "https://symfony.com", + "keywords": [ + "mercure", + "push", + "sse", + "updates" + ], + "support": { + "issues": "https://github.com/symfony/mercure/issues", + "source": "https://github.com/symfony/mercure/tree/v0.6.5" + }, + "funding": [ + { + "url": "https://github.com/dunglas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/mercure", + "type": "tidelift" + } + ], + "time": "2024-04-08T12:51:34+00:00" + }, + { + "name": "symfony/mercure-bundle", + "version": "v0.3.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/mercure-bundle.git", + "reference": "77435d740b228e9f5f3f065b6db564f85f2cdb64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mercure-bundle/zipball/77435d740b228e9f5f3f065b6db564f85f2cdb64", + "reference": "77435d740b228e9f5f3f065b6db564f85f2cdb64", + "shasum": "" + }, + "require": { + "lcobucci/jwt": "^3.4|^4.0|^5.0", + "php": ">=7.1.3", + "symfony/config": "^4.4|^5.0|^6.0|^7.0", + "symfony/dependency-injection": "^4.4|^5.4|^6.0|^7.0", + "symfony/http-kernel": "^4.4|^5.0|^6.0|^7.0", + "symfony/mercure": "^0.6.1", + "symfony/web-link": "^4.4|^5.0|^6.0|^7.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^4.3.7|^5.0|^6.0|^7.0", + "symfony/stopwatch": "^4.3.7|^5.0|^6.0|^7.0", + "symfony/ux-turbo": "*", + "symfony/var-dumper": "^4.3.7|^5.0|^6.0|^7.0" + }, + "suggest": { + "symfony/messenger": "To use the Messenger integration" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MercureBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony MercureBundle", + "homepage": "https://symfony.com", + "keywords": [ + "mercure", + "push", + "sse", + "updates" + ], + "support": { + "issues": "https://github.com/symfony/mercure-bundle/issues", + "source": "https://github.com/symfony/mercure-bundle/tree/v0.3.9" + }, + "funding": [ + { + "url": "https://github.com/dunglas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/mercure-bundle", + "type": "tidelift" + } + ], + "time": "2024-05-31T09:07:18+00:00" + }, + { + "name": "symfony/messenger", + "version": "v7.3.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "d9e04339404ba2dcd04c24172125516dc0e06c35" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/d9e04339404ba2dcd04c24172125516dc0e06c35", + "reference": "d9e04339404ba2dcd04c24172125516dc0e06c35", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/clock": "^6.4|^7.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<7.2", "symfony/event-dispatcher": "<6.4", "symfony/event-dispatcher-contracts": "<2.5", "symfony/framework-bundle": "<6.4", @@ -4447,7 +4673,7 @@ "description": "Helps applications send and receive messages to/from other applications or via message queues", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/messenger/tree/v7.3.1" + "source": "https://github.com/symfony/messenger/tree/v7.3.3" }, "funding": [ { @@ -4458,25 +4684,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-08-13T11:49:31+00:00" }, { "name": "symfony/mime", - "version": "v7.3.0", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9" + "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", - "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", + "url": "https://api.github.com/repos/symfony/mime/zipball/b1b828f69cbaf887fa835a091869e55df91d0e35", + "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35", "shasum": "" }, "require": { @@ -4531,7 +4761,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.3.0" + "source": "https://github.com/symfony/mime/tree/v7.3.4" }, "funding": [ { @@ -4542,25 +4772,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-19T08:51:26+00:00" + "time": "2025-09-16T08:38:17+00:00" }, { "name": "symfony/monolog-bridge", - "version": "v7.3.0", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "1b188c8abbbef25b111da878797514b7a8d33990" + "reference": "c66a65049c75f3ddf03d73c8c9ed61405779ce47" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/1b188c8abbbef25b111da878797514b7a8d33990", - "reference": "1b188c8abbbef25b111da878797514b7a8d33990", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/c66a65049c75f3ddf03d73c8c9ed61405779ce47", + "reference": "c66a65049c75f3ddf03d73c8c9ed61405779ce47", "shasum": "" }, "require": { @@ -4609,7 +4843,7 @@ "description": "Provides integration for Monolog with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/monolog-bridge/tree/v7.3.0" + "source": "https://github.com/symfony/monolog-bridge/tree/v7.3.5" }, "funding": [ { @@ -4620,12 +4854,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-21T12:17:46+00:00" + "time": "2025-10-14T19:16:15+00:00" }, { "name": "symfony/monolog-bundle", @@ -4710,16 +4948,16 @@ }, { "name": "symfony/notifier", - "version": "v7.3.0", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/notifier.git", - "reference": "9e68a3266c8b0381f8756022b1c1ba3c0264416e" + "reference": "33e91495d9674b6ba5e2a1de810902ba976156f5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/notifier/zipball/9e68a3266c8b0381f8756022b1c1ba3c0264416e", - "reference": "9e68a3266c8b0381f8756022b1c1ba3c0264416e", + "url": "https://api.github.com/repos/symfony/notifier/zipball/33e91495d9674b6ba5e2a1de810902ba976156f5", + "reference": "33e91495d9674b6ba5e2a1de810902ba976156f5", "shasum": "" }, "require": { @@ -4768,7 +5006,7 @@ "notifier" ], "support": { - "source": "https://github.com/symfony/notifier/tree/v7.3.0" + "source": "https://github.com/symfony/notifier/tree/v7.3.3" }, "funding": [ { @@ -4779,25 +5017,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-01T12:12:53+00:00" + "time": "2025-08-13T11:49:31+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.3.0", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "afb9a8038025e5dbc657378bfab9198d75f10fca" + "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/afb9a8038025e5dbc657378bfab9198d75f10fca", - "reference": "afb9a8038025e5dbc657378bfab9198d75f10fca", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/0ff2f5c3df08a395232bbc3c2eb7e84912df911d", + "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d", "shasum": "" }, "require": { @@ -4835,7 +5077,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.3.0" + "source": "https://github.com/symfony/options-resolver/tree/v7.3.3" }, "funding": [ { @@ -4846,12 +5088,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-04T13:12:05+00:00" + "time": "2025-08-05T10:16:07+00:00" }, { "name": "symfony/password-hasher", @@ -4927,16 +5173,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", "shasum": "" }, "require": { @@ -4985,7 +5231,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" }, "funding": [ { @@ -4996,25 +5242,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-06-27T09:58:17+00:00" }, { "name": "symfony/polyfill-intl-icu", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-icu.git", - "reference": "763d2a91fea5681509ca01acbc1c5e450d127811" + "reference": "bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/763d2a91fea5681509ca01acbc1c5e450d127811", - "reference": "763d2a91fea5681509ca01acbc1c5e450d127811", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c", + "reference": "bfc8fa13dbaf21d69114b0efcd72ab700fb04d0c", "shasum": "" }, "require": { @@ -5069,7 +5319,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.33.0" }, "funding": [ { @@ -5080,16 +5330,20 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-21T18:38:29+00:00" + "time": "2025-06-20T22:24:30+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", @@ -5152,7 +5406,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" }, "funding": [ { @@ -5163,6 +5417,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -5172,7 +5430,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -5233,7 +5491,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" }, "funding": [ { @@ -5244,6 +5502,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -5253,7 +5515,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -5314,7 +5576,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" }, "funding": [ { @@ -5325,6 +5587,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -5334,16 +5600,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491" + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", "shasum": "" }, "require": { @@ -5390,7 +5656,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" }, "funding": [ { @@ -5401,25 +5667,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-07-08T02:45:35+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "000df7860439609837bbe28670b0be15783b7fbf" + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/000df7860439609837bbe28670b0be15783b7fbf", - "reference": "000df7860439609837bbe28670b0be15783b7fbf", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", "shasum": "" }, "require": { @@ -5466,7 +5736,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" }, "funding": [ { @@ -5477,25 +5747,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-20T12:04:08+00:00" + "time": "2025-06-24T13:30:11+00:00" }, { "name": "symfony/process", - "version": "v7.3.0", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af" + "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", - "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "url": "https://api.github.com/repos/symfony/process/zipball/f24f8f316367b30810810d4eb30c543d7003ff3b", + "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b", "shasum": "" }, "require": { @@ -5527,7 +5801,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.3.0" + "source": "https://github.com/symfony/process/tree/v7.3.4" }, "funding": [ { @@ -5538,25 +5812,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-17T09:11:12+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/property-access", - "version": "v7.3.1", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "518d15c8cca726ebe665dcd7154074584cf862e8" + "reference": "4a4389e5c8bd1d0320d80a23caa6a1ac71cb81a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/518d15c8cca726ebe665dcd7154074584cf862e8", - "reference": "518d15c8cca726ebe665dcd7154074584cf862e8", + "url": "https://api.github.com/repos/symfony/property-access/zipball/4a4389e5c8bd1d0320d80a23caa6a1ac71cb81a7", + "reference": "4a4389e5c8bd1d0320d80a23caa6a1ac71cb81a7", "shasum": "" }, "require": { @@ -5603,7 +5881,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v7.3.1" + "source": "https://github.com/symfony/property-access/tree/v7.3.3" }, "funding": [ { @@ -5614,32 +5892,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-24T04:04:43+00:00" + "time": "2025-08-04T15:15:28+00:00" }, { "name": "symfony/property-info", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "90586acbf2a6dd13bee4f09f09111c8bd4773970" + "reference": "0b346ed259dc5da43535caf243005fe7d4b0f051" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/90586acbf2a6dd13bee4f09f09111c8bd4773970", - "reference": "90586acbf2a6dd13bee4f09f09111c8bd4773970", + "url": "https://api.github.com/repos/symfony/property-info/zipball/0b346ed259dc5da43535caf243005fe7d4b0f051", + "reference": "0b346ed259dc5da43535caf243005fe7d4b0f051", "shasum": "" }, "require": { "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/string": "^6.4|^7.0", - "symfony/type-info": "~7.2.8|^7.3.1" + "symfony/type-info": "^7.3.5" }, "conflict": { "phpdocumentor/reflection-docblock": "<5.2", @@ -5689,7 +5971,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v7.3.1" + "source": "https://github.com/symfony/property-info/tree/v7.3.5" }, "funding": [ { @@ -5700,25 +5982,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-10-05T22:12:41+00:00" }, { "name": "symfony/routing", - "version": "v7.3.0", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "8e213820c5fea844ecea29203d2a308019007c15" + "reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/8e213820c5fea844ecea29203d2a308019007c15", - "reference": "8e213820c5fea844ecea29203d2a308019007c15", + "url": "https://api.github.com/repos/symfony/routing/zipball/8dc648e159e9bac02b703b9fbd937f19ba13d07c", + "reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c", "shasum": "" }, "require": { @@ -5770,7 +6056,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.3.0" + "source": "https://github.com/symfony/routing/tree/v7.3.4" }, "funding": [ { @@ -5781,25 +6067,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-24T20:43:28+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/runtime", - "version": "v7.3.1", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/runtime.git", - "reference": "9516056d432f8acdac9458eb41b80097da7a05c9" + "reference": "3550e2711e30bfa5d808514781cd52d1cc1d9e9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/9516056d432f8acdac9458eb41b80097da7a05c9", - "reference": "9516056d432f8acdac9458eb41b80097da7a05c9", + "url": "https://api.github.com/repos/symfony/runtime/zipball/3550e2711e30bfa5d808514781cd52d1cc1d9e9f", + "reference": "3550e2711e30bfa5d808514781cd52d1cc1d9e9f", "shasum": "" }, "require": { @@ -5849,7 +6139,7 @@ "runtime" ], "support": { - "source": "https://github.com/symfony/runtime/tree/v7.3.1" + "source": "https://github.com/symfony/runtime/tree/v7.3.4" }, "funding": [ { @@ -5860,25 +6150,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-13T07:48:40+00:00" + "time": "2025-09-11T15:31:28+00:00" }, { "name": "symfony/security-bundle", - "version": "v7.3.1", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/security-bundle.git", - "reference": "428a281fd66c8358adc2259c8578e6d81fbb7079" + "reference": "f750d9abccbeaa433c56f6a4eb2073166476a75a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-bundle/zipball/428a281fd66c8358adc2259c8578e6d81fbb7079", - "reference": "428a281fd66c8358adc2259c8578e6d81fbb7079", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/f750d9abccbeaa433c56f6a4eb2073166476a75a", + "reference": "f750d9abccbeaa433c56f6a4eb2073166476a75a", "shasum": "" }, "require": { @@ -5955,7 +6249,7 @@ "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-bundle/tree/v7.3.1" + "source": "https://github.com/symfony/security-bundle/tree/v7.3.4" }, "funding": [ { @@ -5966,25 +6260,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-24T04:04:43+00:00" + "time": "2025-09-22T15:31:00+00:00" }, { "name": "symfony/security-core", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "fafab1003a31e51506e1a0a83e81c072211d81ba" + "reference": "772a7c1eddd8bf8a977a67e6e8adc59650c604eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/fafab1003a31e51506e1a0a83e81c072211d81ba", - "reference": "fafab1003a31e51506e1a0a83e81c072211d81ba", + "url": "https://api.github.com/repos/symfony/security-core/zipball/772a7c1eddd8bf8a977a67e6e8adc59650c604eb", + "reference": "772a7c1eddd8bf8a977a67e6e8adc59650c604eb", "shasum": "" }, "require": { @@ -6042,7 +6340,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v7.3.1" + "source": "https://github.com/symfony/security-core/tree/v7.3.5" }, "funding": [ { @@ -6053,12 +6351,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-23T07:28:50+00:00" + "time": "2025-10-24T14:27:20+00:00" }, { "name": "symfony/security-csrf", @@ -6132,16 +6434,16 @@ }, { "name": "symfony/security-http", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/security-http.git", - "reference": "b7182ed0fd2359297f78ff6d407265168255ea84" + "reference": "e79a63fd5dec6e5b1ba31227e98d860a4f8ba95c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-http/zipball/b7182ed0fd2359297f78ff6d407265168255ea84", - "reference": "b7182ed0fd2359297f78ff6d407265168255ea84", + "url": "https://api.github.com/repos/symfony/security-http/zipball/e79a63fd5dec6e5b1ba31227e98d860a4f8ba95c", + "reference": "e79a63fd5dec6e5b1ba31227e98d860a4f8ba95c", "shasum": "" }, "require": { @@ -6200,7 +6502,7 @@ "description": "Symfony Security Component - HTTP Integration", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-http/tree/v7.3.1" + "source": "https://github.com/symfony/security-http/tree/v7.3.5" }, "funding": [ { @@ -6211,31 +6513,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-24T04:04:43+00:00" + "time": "2025-10-13T09:30:10+00:00" }, { "name": "symfony/serializer", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "feaf837cedbbc8287986602223175d3fd639922d" + "reference": "ba2e50a5f2870c93f0f47ca1a4e56e4bbe274035" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/feaf837cedbbc8287986602223175d3fd639922d", - "reference": "feaf837cedbbc8287986602223175d3fd639922d", + "url": "https://api.github.com/repos/symfony/serializer/zipball/ba2e50a5f2870c93f0f47ca1a4e56e4bbe274035", + "reference": "ba2e50a5f2870c93f0f47ca1a4e56e4bbe274035", "shasum": "" }, "require": { "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-ctype": "~1.8" + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-php84": "^1.30" }, "conflict": { "phpdocumentor/reflection-docblock": "<3.2.2", @@ -6265,7 +6572,7 @@ "symfony/property-access": "^6.4|^7.0", "symfony/property-info": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/type-info": "^7.1", + "symfony/type-info": "^7.1.8", "symfony/uid": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0", "symfony/var-dumper": "^6.4|^7.0", @@ -6298,7 +6605,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.3.1" + "source": "https://github.com/symfony/serializer/tree/v7.3.5" }, "funding": [ { @@ -6309,12 +6616,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-10-08T11:26:21+00:00" }, { "name": "symfony/service-contracts", @@ -6401,16 +6712,16 @@ }, { "name": "symfony/stimulus-bundle", - "version": "v2.30.0", + "version": "v2.31.0", "source": { "type": "git", "url": "https://github.com/symfony/stimulus-bundle.git", - "reference": "668b9efe9d0ab8b4e50091263171609e0459c0c8" + "reference": "c5ea8ee2ccd45447b7f4b6b82f704ee5e76127f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/668b9efe9d0ab8b4e50091263171609e0459c0c8", - "reference": "668b9efe9d0ab8b4e50091263171609e0459c0c8", + "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/c5ea8ee2ccd45447b7f4b6b82f704ee5e76127f0", + "reference": "c5ea8ee2ccd45447b7f4b6b82f704ee5e76127f0", "shasum": "" }, "require": { @@ -6450,7 +6761,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/stimulus-bundle/tree/v2.30.0" + "source": "https://github.com/symfony/stimulus-bundle/tree/v2.31.0" }, "funding": [ { @@ -6470,7 +6781,7 @@ "type": "tidelift" } ], - "time": "2025-08-27T15:25:48+00:00" + "time": "2025-09-24T13:27:42+00:00" }, { "name": "symfony/stopwatch", @@ -6536,16 +6847,16 @@ }, { "name": "symfony/string", - "version": "v7.3.0", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125" + "reference": "f96476035142921000338bad71e5247fbc138872" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f3570b8c61ca887a9e2938e85cb6458515d2b125", - "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125", + "url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872", + "reference": "f96476035142921000338bad71e5247fbc138872", "shasum": "" }, "require": { @@ -6560,7 +6871,6 @@ }, "require-dev": { "symfony/emoji": "^7.1", - "symfony/error-handler": "^6.4|^7.0", "symfony/http-client": "^6.4|^7.0", "symfony/intl": "^6.4|^7.0", "symfony/translation-contracts": "^2.5|^3.0", @@ -6603,7 +6913,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.0" + "source": "https://github.com/symfony/string/tree/v7.3.4" }, "funding": [ { @@ -6614,25 +6924,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-20T20:19:01+00:00" + "time": "2025-09-11T14:36:48+00:00" }, { "name": "symfony/translation", - "version": "v7.3.1", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "241d5ac4910d256660238a7ecf250deba4c73063" + "reference": "ec25870502d0c7072d086e8ffba1420c85965174" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/241d5ac4910d256660238a7ecf250deba4c73063", - "reference": "241d5ac4910d256660238a7ecf250deba4c73063", + "url": "https://api.github.com/repos/symfony/translation/zipball/ec25870502d0c7072d086e8ffba1420c85965174", + "reference": "ec25870502d0c7072d086e8ffba1420c85965174", "shasum": "" }, "require": { @@ -6699,7 +7013,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.3.1" + "source": "https://github.com/symfony/translation/tree/v7.3.4" }, "funding": [ { @@ -6710,12 +7024,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-09-07T11:39:36+00:00" }, { "name": "symfony/translation-contracts", @@ -6797,16 +7115,16 @@ }, { "name": "symfony/twig-bridge", - "version": "v7.3.0", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "082eb15d8a4f9afee0acc4709fbe3aaf26d48891" + "reference": "33558f013b7f6ed72805527c8405cae0062e47c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/082eb15d8a4f9afee0acc4709fbe3aaf26d48891", - "reference": "082eb15d8a4f9afee0acc4709fbe3aaf26d48891", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/33558f013b7f6ed72805527c8405cae0062e47c5", + "reference": "33558f013b7f6ed72805527c8405cae0062e47c5", "shasum": "" }, "require": { @@ -6888,7 +7206,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v7.3.0" + "source": "https://github.com/symfony/twig-bridge/tree/v7.3.3" }, "funding": [ { @@ -6899,25 +7217,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-19T13:28:56+00:00" + "time": "2025-08-18T13:10:53+00:00" }, { "name": "symfony/twig-bundle", - "version": "v7.3.1", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/twig-bundle.git", - "reference": "bc23c11d9716fc2261ee26a32e654b0e8b1b1896" + "reference": "da5c778a8416fcce5318737c4d944f6fa2bb3f81" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/bc23c11d9716fc2261ee26a32e654b0e8b1b1896", - "reference": "bc23c11d9716fc2261ee26a32e654b0e8b1b1896", + "url": "https://api.github.com/repos/symfony/twig-bundle/zipball/da5c778a8416fcce5318737c4d944f6fa2bb3f81", + "reference": "da5c778a8416fcce5318737c4d944f6fa2bb3f81", "shasum": "" }, "require": { @@ -6972,7 +7294,7 @@ "description": "Provides a tight integration of Twig into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bundle/tree/v7.3.1" + "source": "https://github.com/symfony/twig-bundle/tree/v7.3.4" }, "funding": [ { @@ -6983,25 +7305,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-24T04:04:43+00:00" + "time": "2025-09-10T12:00:31+00:00" }, { "name": "symfony/type-info", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "5fa6e25e4195e73ce9e457b521ac5e61ec271150" + "reference": "8b36f41421160db56914f897b57eaa6a830758b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/5fa6e25e4195e73ce9e457b521ac5e61ec271150", - "reference": "5fa6e25e4195e73ce9e457b521ac5e61ec271150", + "url": "https://api.github.com/repos/symfony/type-info/zipball/8b36f41421160db56914f897b57eaa6a830758b3", + "reference": "8b36f41421160db56914f897b57eaa6a830758b3", "shasum": "" }, "require": { @@ -7051,7 +7377,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.3.1" + "source": "https://github.com/symfony/type-info/tree/v7.3.5" }, "funding": [ { @@ -7062,25 +7388,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-10-16T12:30:12+00:00" }, { "name": "symfony/ux-chartjs", - "version": "v2.30.0", + "version": "v2.31.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-chartjs.git", - "reference": "b35da28b549347b4b18552f1880ee1144ed88d3d" + "reference": "17f3e24bca0444a2316e408c4d4d13d89598a187" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-chartjs/zipball/b35da28b549347b4b18552f1880ee1144ed88d3d", - "reference": "b35da28b549347b4b18552f1880ee1144ed88d3d", + "url": "https://api.github.com/repos/symfony/ux-chartjs/zipball/17f3e24bca0444a2316e408c4d4d13d89598a187", + "reference": "17f3e24bca0444a2316e408c4d4d13d89598a187", "shasum": "" }, "require": { @@ -7131,7 +7461,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/ux-chartjs/tree/v2.30.0" + "source": "https://github.com/symfony/ux-chartjs/tree/v2.31.0" }, "funding": [ { @@ -7151,51 +7481,56 @@ "type": "tidelift" } ], - "time": "2025-08-27T15:25:48+00:00" + "time": "2025-10-16T07:24:06+00:00" }, { - "name": "symfony/ux-turbo", - "version": "v2.27.0", + "name": "symfony/ux-live-component", + "version": "v2.31.0", "source": { "type": "git", - "url": "https://github.com/symfony/ux-turbo.git", - "reference": "b9ce9b30a9cf9bbd090c7ad290bdaf84a0e100b2" + "url": "https://github.com/symfony/ux-live-component.git", + "reference": "10776be28e15b731ba9d6e3eb43e840434442d67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-turbo/zipball/b9ce9b30a9cf9bbd090c7ad290bdaf84a0e100b2", - "reference": "b9ce9b30a9cf9bbd090c7ad290bdaf84a0e100b2", + "url": "https://api.github.com/repos/symfony/ux-live-component/zipball/10776be28e15b731ba9d6e3eb43e840434442d67", + "reference": "10776be28e15b731ba9d6e3eb43e840434442d67", "shasum": "" }, "require": { "php": ">=8.1", - "symfony/stimulus-bundle": "^2.9.1" + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/property-access": "^5.4.5|^6.0|^7.0|^8.0", + "symfony/property-info": "^5.4|^6.0|^7.0|^8.0", + "symfony/stimulus-bundle": "^2.9", + "symfony/ux-twig-component": "^2.25.1", + "twig/twig": "^3.10.3" }, "conflict": { - "symfony/flex": "<1.13" + "symfony/config": "<5.4.0", + "symfony/property-info": "~7.0.0", + "symfony/type-info": "<7.2" }, "require-dev": { - "dbrekelmans/bdi": "dev-main", + "doctrine/annotations": "^1.0|^2.0", + "doctrine/collections": "^1.6.8|^2.0", "doctrine/doctrine-bundle": "^2.4.3", - "doctrine/orm": "^2.8 | 3.0", - "php-webdriver/webdriver": "^1.15", - "phpstan/phpstan": "^2.1.17", - "symfony/asset-mapper": "^6.4|^7.0", - "symfony/debug-bundle": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/form": "^5.4|^6.0|^7.0", - "symfony/framework-bundle": "^6.4|^7.0", - "symfony/mercure-bundle": "^0.3.7", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/panther": "^2.2", - "symfony/phpunit-bridge": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|6.3.*|^7.0", - "symfony/property-access": "^5.4|^6.0|^7.0", - "symfony/security-core": "^5.4|^6.0|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/twig-bundle": "^6.4|^7.0", - "symfony/ux-twig-component": "^2.21", - "symfony/web-profiler-bundle": "^5.4|^6.0|^7.0" + "doctrine/orm": "^2.9.4|^3.0", + "doctrine/persistence": "^2.5.2|^3.0", + "phpdocumentor/reflection-docblock": "5.x-dev", + "symfony/dependency-injection": "^5.4|^6.0|^7.0|^8.0", + "symfony/expression-language": "^5.4|^6.0|^7.0|^8.0", + "symfony/form": "^5.4|^6.0|^7.0|^8.0", + "symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/options-resolver": "^5.4|^6.0|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.1|^7.0|^8.0", + "symfony/security-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/serializer": "^5.4|^6.0|^7.0|^8.0", + "symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/uid": "^5.4|^6.0|^7.0|^8.0", + "symfony/validator": "^5.4|^6.0|^7.0|^8.0", + "zenstruck/browser": "^1.2.0", + "zenstruck/foundry": "^2.0" }, "type": "symfony-bundle", "extra": { @@ -7206,7 +7541,7 @@ }, "autoload": { "psr-4": { - "Symfony\\UX\\Turbo\\": "src/" + "Symfony\\UX\\LiveComponent\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -7214,19 +7549,115 @@ "MIT" ], "authors": [ - { - "name": "Kévin Dunglas", - "email": "kevin@dunglas.fr" - }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Hotwire Turbo integration for Symfony", + "description": "Live components for Symfony", "homepage": "https://symfony.com", "keywords": [ - "hotwire", + "components", + "symfony-ux", + "twig" + ], + "support": { + "source": "https://github.com/symfony/ux-live-component/tree/v2.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-22T02:51:40+00:00" + }, + { + "name": "symfony/ux-turbo", + "version": "v2.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/ux-turbo.git", + "reference": "06d5e4cf4573efe4faf648f3810a28c63684c706" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ux-turbo/zipball/06d5e4cf4573efe4faf648f3810a28c63684c706", + "reference": "06d5e4cf4573efe4faf648f3810a28c63684c706", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/stimulus-bundle": "^2.9.1" + }, + "conflict": { + "symfony/flex": "<1.13" + }, + "require-dev": { + "dbrekelmans/bdi": "dev-main", + "doctrine/doctrine-bundle": "^2.4.3", + "doctrine/orm": "^2.8|^3.0", + "php-webdriver/webdriver": "^1.15", + "phpstan/phpstan": "^2.1.17", + "symfony/asset-mapper": "^6.4|^7.0|^8.0", + "symfony/debug-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/expression-language": "^5.4|^6.0|^7.0|^8.0", + "symfony/form": "^5.4|^6.0|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/mercure-bundle": "^0.3.7", + "symfony/messenger": "^5.4|^6.0|^7.0|^8.0", + "symfony/panther": "^2.2", + "symfony/phpunit-bridge": "^5.4|^6.0|^7.0|^8.0", + "symfony/process": "^5.4|6.3.*|^7.0|^8.0", + "symfony/property-access": "^5.4|^6.0|^7.0|^8.0", + "symfony/security-core": "^5.4|^6.0|^7.0|^8.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0|^8.0", + "symfony/twig-bundle": "^6.4|^7.0|^8.0", + "symfony/ux-twig-component": "^2.21", + "symfony/web-profiler-bundle": "^5.4|^6.0|^7.0|^8.0" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ux", + "name": "symfony/ux" + } + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\Turbo\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "kevin@dunglas.fr" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Hotwire Turbo integration for Symfony", + "homepage": "https://symfony.com", + "keywords": [ + "hotwire", "javascript", "mercure", "symfony-ux", @@ -7234,7 +7665,94 @@ "turbo-stream" ], "support": { - "source": "https://github.com/symfony/ux-turbo/tree/v2.27.0" + "source": "https://github.com/symfony/ux-turbo/tree/v2.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-16T07:24:06+00:00" + }, + { + "name": "symfony/ux-twig-component", + "version": "v2.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/ux-twig-component.git", + "reference": "6f7ecc103cdb51adb6d76d32e374fcd1d33ff2fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/6f7ecc103cdb51adb6d76d32e374fcd1d33ff2fa", + "reference": "6f7ecc103cdb51adb6d76d32e374fcd1d33ff2fa", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/dependency-injection": "^5.4|^6.0|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.2|^3.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0|^8.0", + "symfony/property-access": "^5.4|^6.0|^7.0|^8.0", + "twig/twig": "^3.10.3" + }, + "conflict": { + "symfony/config": "<5.4.0" + }, + "require-dev": { + "symfony/console": "^5.4|^6.0|^7.0|^8.0", + "symfony/css-selector": "^5.4|^6.0|^7.0|^8.0", + "symfony/dom-crawler": "^5.4|^6.0|^7.0|^8.0", + "symfony/framework-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.0|^7.0|^8.0", + "symfony/stimulus-bundle": "^2.9.1", + "symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.15|^2.3.0" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ux", + "name": "symfony/ux" + } + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\TwigComponent\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Twig components for Symfony", + "homepage": "https://symfony.com", + "keywords": [ + "components", + "symfony-ux", + "twig" + ], + "support": { + "source": "https://github.com/symfony/ux-twig-component/tree/v2.31.0" }, "funding": [ { @@ -7245,25 +7763,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-06T20:27:21+00:00" + "time": "2025-10-17T06:14:35+00:00" }, { "name": "symfony/validator", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/validator.git", - "reference": "e2f2497c869fc57446f735fbf00cff4de32ae8c3" + "reference": "724086992fb7c7882d05c9d2219d70401ab9fdda" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/validator/zipball/e2f2497c869fc57446f735fbf00cff4de32ae8c3", - "reference": "e2f2497c869fc57446f735fbf00cff4de32ae8c3", + "url": "https://api.github.com/repos/symfony/validator/zipball/724086992fb7c7882d05c9d2219d70401ab9fdda", + "reference": "724086992fb7c7882d05c9d2219d70401ab9fdda", "shasum": "" }, "require": { @@ -7302,7 +7824,7 @@ "symfony/property-info": "^6.4|^7.0", "symfony/string": "^6.4|^7.0", "symfony/translation": "^6.4.3|^7.0.3", - "symfony/type-info": "^7.1", + "symfony/type-info": "^7.1.8", "symfony/yaml": "^6.4|^7.0" }, "type": "library", @@ -7332,7 +7854,7 @@ "description": "Provides tools to validate values", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/validator/tree/v7.3.1" + "source": "https://github.com/symfony/validator/tree/v7.3.5" }, "funding": [ { @@ -7343,25 +7865,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-26T13:22:23+00:00" + "time": "2025-10-24T14:27:20+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42" + "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", - "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/476c4ae17f43a9a36650c69879dcf5b1e6ae724d", + "reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d", "shasum": "" }, "require": { @@ -7373,7 +7899,6 @@ "symfony/console": "<6.4" }, "require-dev": { - "ext-iconv": "*", "symfony/console": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "symfony/process": "^6.4|^7.0", @@ -7416,7 +7941,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.1" + "source": "https://github.com/symfony/var-dumper/tree/v7.3.5" }, "funding": [ { @@ -7427,25 +7952,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-27T19:55:54+00:00" + "time": "2025-09-27T09:00:46+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.3.0", + "version": "v7.3.4", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "c9a1168891b5aaadfd6332ef44393330b3498c4c" + "reference": "0f020b544a30a7fe8ba972e53ee48a74c0bc87f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/c9a1168891b5aaadfd6332ef44393330b3498c4c", - "reference": "c9a1168891b5aaadfd6332ef44393330b3498c4c", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/0f020b544a30a7fe8ba972e53ee48a74c0bc87f4", + "reference": "0f020b544a30a7fe8ba972e53ee48a74c0bc87f4", "shasum": "" }, "require": { @@ -7493,7 +8022,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.3.0" + "source": "https://github.com/symfony/var-exporter/tree/v7.3.4" }, "funding": [ { @@ -7504,12 +8033,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-15T09:04:05+00:00" + "time": "2025-09-11T10:12:26+00:00" }, { "name": "symfony/web-link", @@ -7596,16 +8129,16 @@ }, { "name": "symfony/yaml", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb" + "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/0c3555045a46ab3cd4cc5a69d161225195230edb", - "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb", + "url": "https://api.github.com/repos/symfony/yaml/zipball/90208e2fc6f68f613eae7ca25a2458a931b1bacc", + "reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc", "shasum": "" }, "require": { @@ -7648,7 +8181,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.3.1" + "source": "https://github.com/symfony/yaml/tree/v7.3.5" }, "funding": [ { @@ -7659,12 +8192,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-03T06:57:57+00:00" + "time": "2025-09-27T09:00:46+00:00" }, { "name": "symfonycasts/sass-bundle", @@ -7723,16 +8260,16 @@ }, { "name": "twig/extra-bundle", - "version": "v3.21.0", + "version": "v3.22.0", "source": { "type": "git", "url": "https://github.com/twigphp/twig-extra-bundle.git", - "reference": "62d1cf47a1aa009cbd07b21045b97d3d5cb79896" + "reference": "6d253f0fe28a83a045497c8fb3ea9bfe84e82cf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/62d1cf47a1aa009cbd07b21045b97d3d5cb79896", - "reference": "62d1cf47a1aa009cbd07b21045b97d3d5cb79896", + "url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/6d253f0fe28a83a045497c8fb3ea9bfe84e82cf4", + "reference": "6d253f0fe28a83a045497c8fb3ea9bfe84e82cf4", "shasum": "" }, "require": { @@ -7742,7 +8279,7 @@ "twig/twig": "^3.2|^4.0" }, "require-dev": { - "league/commonmark": "^1.0|^2.0", + "league/commonmark": "^2.7", "symfony/phpunit-bridge": "^6.4|^7.0", "twig/cache-extra": "^3.0", "twig/cssinliner-extra": "^3.0", @@ -7781,7 +8318,7 @@ "twig" ], "support": { - "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.21.0" + "source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.22.0" }, "funding": [ { @@ -7793,20 +8330,20 @@ "type": "tidelift" } ], - "time": "2025-02-19T14:29:33+00:00" + "time": "2025-09-15T05:57:37+00:00" }, { "name": "twig/twig", - "version": "v3.21.1", + "version": "v3.22.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d" + "reference": "4509984193026de413baf4ba80f68590a7f2c51d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/285123877d4dd97dd7c11842ac5fb7e86e60d81d", - "reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/4509984193026de413baf4ba80f68590a7f2c51d", + "reference": "4509984193026de413baf4ba80f68590a7f2c51d", "shasum": "" }, "require": { @@ -7860,7 +8397,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.21.1" + "source": "https://github.com/twigphp/Twig/tree/v3.22.0" }, "funding": [ { @@ -7872,32 +8409,32 @@ "type": "tidelift" } ], - "time": "2025-05-03T07:21:55+00:00" + "time": "2025-10-29T15:56:47+00:00" }, { "name": "webmozart/assert", - "version": "1.11.0", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", "shasum": "" }, "require": { "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", "php": "^7.2 || ^8.0" }, - "conflict": { - "phpstan/phpstan": "<0.12.20", - "vimeo/psalm": "<4.6.1 || 4.6.2" - }, - "require-dev": { - "phpunit/phpunit": "^8.5.13" + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" }, "type": "library", "extra": { @@ -7928,9 +8465,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.11.0" + "source": "https://github.com/webmozarts/assert/tree/1.12.1" }, - "time": "2022-06-03T18:03:27+00:00" + "time": "2025-10-29T15:56:20+00:00" } ], "packages-dev": [ @@ -7998,6 +8535,94 @@ ], "time": "2022-12-23T10:58:28+00:00" }, + { + "name": "colinodell/json5", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/colinodell/json5.git", + "reference": "5724d21bc5c910c2560af1b8915f0cc0163579c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/colinodell/json5/zipball/5724d21bc5c910c2560af1b8915f0cc0163579c8", + "reference": "5724d21bc5c910c2560af1b8915f0cc0163579c8", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": "^8.0" + }, + "require-dev": { + "mikehaertl/php-shellcommand": "^1.7.0", + "phpstan/phpstan": "^1.10.57", + "scrutinizer/ocular": "^1.9", + "squizlabs/php_codesniffer": "^3.8.1", + "symfony/finder": "^6.0|^7.0", + "symfony/phpunit-bridge": "^7.0.3" + }, + "bin": [ + "bin/json5" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "files": [ + "src/global.php" + ], + "psr-4": { + "ColinODell\\Json5\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Developer" + } + ], + "description": "UTF-8 compatible JSON5 parser for PHP", + "homepage": "https://github.com/colinodell/json5", + "keywords": [ + "JSON5", + "json", + "json5_decode", + "json_decode" + ], + "support": { + "issues": "https://github.com/colinodell/json5/issues", + "source": "https://github.com/colinodell/json5/tree/v3.0.0" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://www.patreon.com/colinodell", + "type": "patreon" + } + ], + "time": "2024-02-09T13:06:12+00:00" + }, { "name": "composer/pcre", "version": "3.3.2", @@ -8144,29 +8769,51 @@ "time": "2024-05-06T16:37:16+00:00" }, { - "name": "evenement/evenement", - "version": "v3.0.2", + "name": "doctrine/data-fixtures", + "version": "2.2.0", "source": { "type": "git", - "url": "https://github.com/igorw/evenement.git", - "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + "url": "https://github.com/doctrine/data-fixtures.git", + "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", - "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/7a615ba135e45d67674bb623d90f34f6c7b6bd97", + "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97", "shasum": "" }, "require": { - "php": ">=7.0" + "doctrine/persistence": "^3.1 || ^4.0", + "php": "^8.1", + "psr/log": "^1.1 || ^2 || ^3" + }, + "conflict": { + "doctrine/dbal": "<3.5 || >=5", + "doctrine/orm": "<2.14 || >=4", + "doctrine/phpcr-odm": "<1.3.0" }, "require-dev": { - "phpunit/phpunit": "^9 || ^6" + "doctrine/coding-standard": "^14", + "doctrine/dbal": "^3.5 || ^4", + "doctrine/mongodb-odm": "^1.3.0 || ^2.0.0", + "doctrine/orm": "^2.14 || ^3", + "ext-sqlite3": "*", + "fig/log-test": "^1", + "phpstan/phpstan": "2.1.31", + "phpunit/phpunit": "10.5.45 || 12.4.0", + "symfony/cache": "^6.4 || ^7", + "symfony/var-exporter": "^6.4 || ^7" + }, + "suggest": { + "alcaeus/mongo-php-adapter": "For using MongoDB ODM 1.3 with PHP 7 (deprecated)", + "doctrine/mongodb-odm": "For loading MongoDB ODM fixtures", + "doctrine/orm": "For loading ORM fixtures", + "doctrine/phpcr-odm": "For loading PHPCR ODM fixtures" }, "type": "library", "autoload": { "psr-4": { - "Evenement\\": "src/" + "Doctrine\\Common\\DataFixtures\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -8175,33 +8822,180 @@ ], "authors": [ { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" } ], - "description": "Événement is a very simple event dispatching library for PHP", + "description": "Data Fixtures for all Doctrine Object Managers", + "homepage": "https://www.doctrine-project.org", "keywords": [ - "event-dispatcher", - "event-emitter" + "database" ], "support": { - "issues": "https://github.com/igorw/evenement/issues", - "source": "https://github.com/igorw/evenement/tree/v3.0.2" + "issues": "https://github.com/doctrine/data-fixtures/issues", + "source": "https://github.com/doctrine/data-fixtures/tree/2.2.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdata-fixtures", + "type": "tidelift" + } + ], + "time": "2025-10-17T20:06:20+00:00" + }, + { + "name": "doctrine/doctrine-fixtures-bundle", + "version": "4.3.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineFixturesBundle.git", + "reference": "11941deb6f2899b91e8b8680b07ffe63899d864b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/11941deb6f2899b91e8b8680b07ffe63899d864b", + "reference": "11941deb6f2899b91e8b8680b07ffe63899d864b", + "shasum": "" + }, + "require": { + "doctrine/data-fixtures": "^2.2", + "doctrine/doctrine-bundle": "^2.2 || ^3.0", + "doctrine/orm": "^2.14.0 || ^3.0", + "doctrine/persistence": "^2.4 || ^3.0 || ^4.0", + "php": "^8.1", + "psr/log": "^2 || ^3", + "symfony/config": "^6.4 || ^7.0", + "symfony/console": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/deprecation-contracts": "^2.1 || ^3", + "symfony/doctrine-bridge": "^6.4.16 || ^7.1.9", + "symfony/http-kernel": "^6.4 || ^7.0" + }, + "conflict": { + "doctrine/dbal": "< 3" + }, + "require-dev": { + "doctrine/coding-standard": "14.0.0", + "phpstan/phpstan": "2.1.11", + "phpunit/phpunit": "^10.5.38 || 11.4.14" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Doctrine\\Bundle\\FixturesBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Doctrine Project", + "homepage": "https://www.doctrine-project.org" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony DoctrineFixturesBundle", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "Fixture", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues", + "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.3.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-fixtures-bundle", + "type": "tidelift" + } + ], + "time": "2025-10-20T06:18:40+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" }, "time": "2023-08-08T05:53:35+00:00" }, { "name": "fidry/cpu-core-counter", - "version": "1.2.0", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/theofidry/cpu-core-counter.git", - "reference": "8520451a140d3f46ac33042715115e290cf5785f" + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/8520451a140d3f46ac33042715115e290cf5785f", - "reference": "8520451a140d3f46ac33042715115e290cf5785f", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", "shasum": "" }, "require": { @@ -8211,10 +9005,10 @@ "fidry/makefile": "^0.2.0", "fidry/php-cs-fixer-config": "^1.1.2", "phpstan/extension-installer": "^1.2.0", - "phpstan/phpstan": "^1.9.2", - "phpstan/phpstan-deprecation-rules": "^1.0.0", - "phpstan/phpstan-phpunit": "^1.2.2", - "phpstan/phpstan-strict-rules": "^1.4.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^8.5.31 || ^9.5.26", "webmozarts/strict-phpunit": "^7.5" }, @@ -8241,7 +9035,7 @@ ], "support": { "issues": "https://github.com/theofidry/cpu-core-counter/issues", - "source": "https://github.com/theofidry/cpu-core-counter/tree/1.2.0" + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" }, "funding": [ { @@ -8249,63 +9043,61 @@ "type": "github" } ], - "time": "2024-08-06T10:04:20+00:00" + "time": "2025-08-14T07:29:31+00:00" }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.82.2", + "version": "v3.89.1", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "684ed3ab41008a2a4848de8bde17eb168c596247" + "reference": "f34967da2866ace090a2b447de1f357356474573" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/684ed3ab41008a2a4848de8bde17eb168c596247", - "reference": "684ed3ab41008a2a4848de8bde17eb168c596247", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/f34967da2866ace090a2b447de1f357356474573", + "reference": "f34967da2866ace090a2b447de1f357356474573", "shasum": "" }, "require": { - "clue/ndjson-react": "^1.0", + "clue/ndjson-react": "^1.3", "composer/semver": "^3.4", "composer/xdebug-handler": "^3.0.5", "ext-filter": "*", "ext-hash": "*", "ext-json": "*", "ext-tokenizer": "*", - "fidry/cpu-core-counter": "^1.2", + "fidry/cpu-core-counter": "^1.3", "php": "^7.4 || ^8.0", "react/child-process": "^0.6.6", - "react/event-loop": "^1.0", - "react/promise": "^2.11 || ^3.0", - "react/socket": "^1.0", - "react/stream": "^1.0", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0", - "symfony/console": "^5.4.45 || ^6.4.13 || ^7.0", - "symfony/event-dispatcher": "^5.4.45 || ^6.4.13 || ^7.0", - "symfony/filesystem": "^5.4.45 || ^6.4.13 || ^7.0", - "symfony/finder": "^5.4.45 || ^6.4.17 || ^7.0", - "symfony/options-resolver": "^5.4.45 || ^6.4.16 || ^7.0", - "symfony/polyfill-mbstring": "^1.32", - "symfony/polyfill-php80": "^1.32", - "symfony/polyfill-php81": "^1.32", - "symfony/process": "^5.4.47 || ^6.4.20 || ^7.2", - "symfony/stopwatch": "^5.4.45 || ^6.4.19 || ^7.0" - }, - "require-dev": { - "facile-it/paraunit": "^1.3.1 || ^2.6", - "infection/infection": "^0.29.14", - "justinrainbow/json-schema": "^5.3 || ^6.4", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0", + "symfony/polyfill-mbstring": "^1.33", + "symfony/polyfill-php80": "^1.33", + "symfony/polyfill-php81": "^1.33", + "symfony/polyfill-php84": "^1.33", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.7", + "infection/infection": "^0.31.0", + "justinrainbow/json-schema": "^6.5", "keradus/cli-executor": "^2.2", "mikey179/vfsstream": "^1.6.12", "php-coveralls/php-coveralls": "^2.8", - "php-cs-fixer/accessible-object": "^1.1", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.6", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.6", - "phpunit/phpunit": "^9.6.23 || ^10.5.47 || ^11.5.25", - "symfony/polyfill-php84": "^1.32", - "symfony/var-dumper": "^5.4.48 || ^6.4.23 || ^7.3.1", - "symfony/yaml": "^5.4.45 || ^6.4.23 || ^7.3.1" + "phpunit/phpunit": "^9.6.25 || ^10.5.53 || ^11.5.34", + "symfony/var-dumper": "^5.4.48 || ^6.4.24 || ^7.3.2", + "symfony/yaml": "^5.4.45 || ^6.4.24 || ^7.3.2" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -8344,30 +9136,536 @@ "standards", "static analysis" ], - "support": { - "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.82.2" - }, - "funding": [ + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.89.1" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2025-10-24T12:05:10+00:00" + }, + { + "name": "infection/abstract-testframework-adapter", + "version": "0.5.0", + "source": { + "type": "git", + "url": "https://github.com/infection/abstract-testframework-adapter.git", + "reference": "18925e20d15d1a5995bb85c9dc09e8751e1e069b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/infection/abstract-testframework-adapter/zipball/18925e20d15d1a5995bb85c9dc09e8751e1e069b", + "reference": "18925e20d15d1a5995bb85c9dc09e8751e1e069b", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.8", + "friendsofphp/php-cs-fixer": "^2.17", + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Infection\\AbstractTestFramework\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Maks Rafalko", + "email": "maks.rafalko@gmail.com" + } + ], + "description": "Abstract Test Framework Adapter for Infection", + "support": { + "issues": "https://github.com/infection/abstract-testframework-adapter/issues", + "source": "https://github.com/infection/abstract-testframework-adapter/tree/0.5.0" + }, + "funding": [ + { + "url": "https://github.com/infection", + "type": "github" + }, + { + "url": "https://opencollective.com/infection", + "type": "open_collective" + } + ], + "time": "2021-08-17T18:49:12+00:00" + }, + { + "name": "infection/extension-installer", + "version": "0.1.2", + "source": { + "type": "git", + "url": "https://github.com/infection/extension-installer.git", + "reference": "9b351d2910b9a23ab4815542e93d541e0ca0cdcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/infection/extension-installer/zipball/9b351d2910b9a23ab4815542e93d541e0ca0cdcf", + "reference": "9b351d2910b9a23ab4815542e93d541e0ca0cdcf", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.1 || ^2.0" + }, + "require-dev": { + "composer/composer": "^1.9 || ^2.0", + "friendsofphp/php-cs-fixer": "^2.18, <2.19", + "infection/infection": "^0.15.2", + "php-coveralls/php-coveralls": "^2.4", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.12.10", + "phpstan/phpstan-phpunit": "^0.12.6", + "phpstan/phpstan-strict-rules": "^0.12.2", + "phpstan/phpstan-webmozart-assert": "^0.12.2", + "phpunit/phpunit": "^9.5", + "vimeo/psalm": "^4.8" + }, + "type": "composer-plugin", + "extra": { + "class": "Infection\\ExtensionInstaller\\Plugin" + }, + "autoload": { + "psr-4": { + "Infection\\ExtensionInstaller\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Maks Rafalko", + "email": "maks.rafalko@gmail.com" + } + ], + "description": "Infection Extension Installer", + "support": { + "issues": "https://github.com/infection/extension-installer/issues", + "source": "https://github.com/infection/extension-installer/tree/0.1.2" + }, + "funding": [ + { + "url": "https://github.com/infection", + "type": "github" + }, + { + "url": "https://opencollective.com/infection", + "type": "open_collective" + } + ], + "time": "2021-10-20T22:08:34+00:00" + }, + { + "name": "infection/include-interceptor", + "version": "0.2.5", + "source": { + "type": "git", + "url": "https://github.com/infection/include-interceptor.git", + "reference": "0cc76d95a79d9832d74e74492b0a30139904bdf7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/infection/include-interceptor/zipball/0cc76d95a79d9832d74e74492b0a30139904bdf7", + "reference": "0cc76d95a79d9832d74e74492b0a30139904bdf7", + "shasum": "" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.16", + "infection/infection": "^0.15.0", + "phan/phan": "^2.4 || ^3", + "php-coveralls/php-coveralls": "^2.2", + "phpstan/phpstan": "^0.12.8", + "phpunit/phpunit": "^8.5", + "vimeo/psalm": "^3.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Infection\\StreamWrapper\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Maks Rafalko", + "email": "maks.rafalko@gmail.com" + } + ], + "description": "Stream Wrapper: Include Interceptor. Allows to replace included (autoloaded) file with another one.", + "support": { + "issues": "https://github.com/infection/include-interceptor/issues", + "source": "https://github.com/infection/include-interceptor/tree/0.2.5" + }, + "funding": [ + { + "url": "https://github.com/infection", + "type": "github" + }, + { + "url": "https://opencollective.com/infection", + "type": "open_collective" + } + ], + "time": "2021-08-09T10:03:57+00:00" + }, + { + "name": "infection/infection", + "version": "0.29.14", + "source": { + "type": "git", + "url": "https://github.com/infection/infection.git", + "reference": "feea2a48a8aeedd3a4d2105167b41a46f0e568a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/infection/infection/zipball/feea2a48a8aeedd3a4d2105167b41a46f0e568a3", + "reference": "feea2a48a8aeedd3a4d2105167b41a46f0e568a3", + "shasum": "" + }, + "require": { + "colinodell/json5": "^2.2 || ^3.0", + "composer-runtime-api": "^2.0", + "composer/xdebug-handler": "^2.0 || ^3.0", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "fidry/cpu-core-counter": "^0.4.0 || ^0.5.0 || ^1.0", + "infection/abstract-testframework-adapter": "^0.5.0", + "infection/extension-installer": "^0.1.0", + "infection/include-interceptor": "^0.2.5", + "infection/mutator": "^0.4", + "justinrainbow/json-schema": "^5.3 || ^6.0", + "nikic/php-parser": "^5.3", + "ondram/ci-detector": "^4.1.0", + "php": "^8.2", + "sanmai/later": "^0.1.1", + "sanmai/pipeline": "^5.1 || ^6", + "sebastian/diff": "^3.0.2 || ^4.0 || ^5.0 || ^6.0 || ^7.0", + "symfony/console": "^6.4 || ^7.0", + "symfony/filesystem": "^6.4 || ^7.0", + "symfony/finder": "^6.4 || ^7.0", + "symfony/process": "^6.4 || ^7.0", + "thecodingmachine/safe": "^v3.0", + "webmozart/assert": "^1.11" + }, + "conflict": { + "antecedent/patchwork": "<2.1.25", + "dg/bypass-finals": "<1.4.1", + "phpunit/php-code-coverage": ">9,<9.1.4 || >9.2.17,<9.2.21" + }, + "require-dev": { + "ext-simplexml": "*", + "fidry/makefile": "^1.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-webmozart-assert": "^2.0", + "phpunit/phpunit": "^11.5", + "rector/rector": "^2.0", + "sidz/phpstan-rules": "^0.5.1", + "symfony/yaml": "^6.4 || ^7.0", + "thecodingmachine/phpstan-safe-rule": "^1.4" + }, + "bin": [ + "bin/infection" + ], + "type": "library", + "autoload": { + "psr-4": { + "Infection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Maks Rafalko", + "email": "maks.rafalko@gmail.com", + "homepage": "https://twitter.com/maks_rafalko" + }, + { + "name": "Oleg Zhulnev", + "homepage": "https://github.com/sidz" + }, + { + "name": "Gert de Pagter", + "homepage": "https://github.com/BackEndTea" + }, + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com", + "homepage": "https://twitter.com/tfidry" + }, + { + "name": "Alexey Kopytko", + "email": "alexey@kopytko.com", + "homepage": "https://www.alexeykopytko.com" + }, + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Infection is a Mutation Testing framework for PHP. The mutation adequacy score can be used to measure the effectiveness of a test set in terms of its ability to detect faults.", + "keywords": [ + "coverage", + "mutant", + "mutation framework", + "mutation testing", + "testing", + "unit testing" + ], + "support": { + "issues": "https://github.com/infection/infection/issues", + "source": "https://github.com/infection/infection/tree/0.29.14" + }, + "funding": [ + { + "url": "https://github.com/infection", + "type": "github" + }, + { + "url": "https://opencollective.com/infection", + "type": "open_collective" + } + ], + "time": "2025-03-02T18:49:12+00:00" + }, + { + "name": "infection/mutator", + "version": "0.4.1", + "source": { + "type": "git", + "url": "https://github.com/infection/mutator.git", + "reference": "3c976d721b02b32f851ee4e15d553ef1e9186d1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/infection/mutator/zipball/3c976d721b02b32f851ee4e15d553ef1e9186d1d", + "reference": "3c976d721b02b32f851ee4e15d553ef1e9186d1d", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^10" + }, + "type": "library", + "autoload": { + "psr-4": { + "Infection\\Mutator\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Maks Rafalko", + "email": "maks.rafalko@gmail.com" + } + ], + "description": "Mutator interface to implement custom mutators (mutation operators) for Infection", + "support": { + "issues": "https://github.com/infection/mutator/issues", + "source": "https://github.com/infection/mutator/tree/0.4.1" + }, + "funding": [ + { + "url": "https://github.com/infection", + "type": "github" + }, + { + "url": "https://opencollective.com/infection", + "type": "open_collective" + } + ], + "time": "2025-04-29T08:19:52+00:00" + }, + { + "name": "justinrainbow/json-schema", + "version": "6.6.0", + "source": { + "type": "git", + "url": "https://github.com/jsonrainbow/json-schema.git", + "reference": "68ba7677532803cc0c5900dd5a4d730537f2b2f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/68ba7677532803cc0c5900dd5a4d730537f2b2f3", + "reference": "68ba7677532803cc0c5900dd5a4d730537f2b2f3", + "shasum": "" + }, + "require": { + "ext-json": "*", + "marc-mabe/php-enum": "^4.0", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.3.0", + "json-schema/json-schema-test-suite": "^23.2", + "marc-mabe/php-enum-phpstan": "^2.0", + "phpspec/prophecy": "^1.19", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^8.5" + }, + "bin": [ + "bin/validate-json" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "JsonSchema\\": "src/JsonSchema/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bruno Prieto Reis", + "email": "bruno.p.reis@gmail.com" + }, + { + "name": "Justin Rainbow", + "email": "justin.rainbow@gmail.com" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Robert Schönthal", + "email": "seroscho@googlemail.com" + } + ], + "description": "A library to validate a json schema.", + "homepage": "https://github.com/jsonrainbow/json-schema", + "keywords": [ + "json", + "schema" + ], + "support": { + "issues": "https://github.com/jsonrainbow/json-schema/issues", + "source": "https://github.com/jsonrainbow/json-schema/tree/6.6.0" + }, + "time": "2025-10-10T11:34:09+00:00" + }, + { + "name": "marc-mabe/php-enum", + "version": "v4.7.2", + "source": { + "type": "git", + "url": "https://github.com/marc-mabe/php-enum.git", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/marc-mabe/php-enum/zipball/bb426fcdd65c60fb3638ef741e8782508fda7eef", + "reference": "bb426fcdd65c60fb3638ef741e8782508fda7eef", + "shasum": "" + }, + "require": { + "ext-reflection": "*", + "php": "^7.1 | ^8.0" + }, + "require-dev": { + "phpbench/phpbench": "^0.16.10 || ^1.0.4", + "phpstan/phpstan": "^1.3.1", + "phpunit/phpunit": "^7.5.20 | ^8.5.22 | ^9.5.11", + "vimeo/psalm": "^4.17.0 | ^5.26.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-3.x": "3.2-dev", + "dev-master": "4.7-dev" + } + }, + "autoload": { + "psr-4": { + "MabeEnum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ { - "url": "https://github.com/keradus", - "type": "github" + "name": "Marc Bennewitz", + "email": "dev@mabe.berlin", + "homepage": "https://mabe.berlin/", + "role": "Lead" } ], - "time": "2025-07-08T21:13:15+00:00" + "description": "Simple and fast implementation of enumerations with native PHP", + "homepage": "https://github.com/marc-mabe/php-enum", + "keywords": [ + "enum", + "enum-map", + "enum-set", + "enumeration", + "enumerator", + "enummap", + "enumset", + "map", + "set", + "type", + "type-hint", + "typehint" + ], + "support": { + "issues": "https://github.com/marc-mabe/php-enum/issues", + "source": "https://github.com/marc-mabe/php-enum/tree/v4.7.2" + }, + "time": "2025-09-14T11:18:39+00:00" }, { "name": "masterminds/html5", - "version": "2.9.0", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6" + "reference": "fcf91eb64359852f00d921887b219479b4f21251" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", - "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", "shasum": "" }, "require": { @@ -8419,22 +9717,22 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.9.0" + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" }, - "time": "2024-03-31T07:05:07+00:00" + "time": "2025-07-25T09:04:22+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.13.3", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "faed855a7b5f4d4637717c2b3863e277116beb36" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/faed855a7b5f4d4637717c2b3863e277116beb36", - "reference": "faed855a7b5f4d4637717c2b3863e277116beb36", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -8473,7 +9771,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.3" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -8481,20 +9779,20 @@ "type": "tidelift" } ], - "time": "2025-07-05T12:25:42+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nikic/php-parser", - "version": "v5.5.0", + "version": "v5.6.2", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "ae59794362fe85e051a58ad36b289443f57be7a9" + "reference": "3a454ca033b9e06b63282ce19562e892747449bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9", - "reference": "ae59794362fe85e051a58ad36b289443f57be7a9", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", + "reference": "3a454ca033b9e06b63282ce19562e892747449bb", "shasum": "" }, "require": { @@ -8513,7 +9811,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -8537,9 +9835,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" }, - "time": "2025-05-31T08:24:38+00:00" + "time": "2025-10-21T19:32:17+00:00" }, { "name": "ondram/ci-detector", @@ -8968,16 +10266,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.17", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "89b5ef665716fa2a52ecd2633f21007a6a349053" - }, + "version": "2.1.31", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/89b5ef665716fa2a52ecd2633f21007a6a349053", - "reference": "89b5ef665716fa2a52ecd2633f21007a6a349053", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ead89849d879fe203ce9292c6ef5e7e76f867b96", + "reference": "ead89849d879fe203ce9292c6ef5e7e76f867b96", "shasum": "" }, "require": { @@ -9022,7 +10315,7 @@ "type": "github" } ], - "time": "2025-05-21T20:55:28+00:00" + "time": "2025-10-10T14:14:11+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", @@ -9073,21 +10366,21 @@ }, { "name": "phpstan/phpstan-phpunit", - "version": "2.0.6", + "version": "2.0.7", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-phpunit.git", - "reference": "6b92469f8a7995e626da3aa487099617b8dfa260" + "reference": "9a9b161baee88a5f5c58d816943cff354ff233dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6b92469f8a7995e626da3aa487099617b8dfa260", - "reference": "6b92469f8a7995e626da3aa487099617b8dfa260", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/9a9b161baee88a5f5c58d816943cff354ff233dc", + "reference": "9a9b161baee88a5f5c58d816943cff354ff233dc", "shasum": "" }, "require": { "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.0.4" + "phpstan/phpstan": "^2.1.18" }, "conflict": { "phpunit/phpunit": "<7.0" @@ -9120,27 +10413,27 @@ "description": "PHPUnit extensions and rules for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-phpunit/issues", - "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.6" + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.7" }, - "time": "2025-03-26T12:47:06+00:00" + "time": "2025-07-13T11:31:46+00:00" }, { "name": "phpstan/phpstan-strict-rules", - "version": "2.0.4", + "version": "2.0.7", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-strict-rules.git", - "reference": "3e139cbe67fafa3588e1dbe27ca50f31fdb6236a" + "reference": "d6211c46213d4181054b3d77b10a5c5cb0d59538" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/3e139cbe67fafa3588e1dbe27ca50f31fdb6236a", - "reference": "3e139cbe67fafa3588e1dbe27ca50f31fdb6236a", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/d6211c46213d4181054b3d77b10a5c5cb0d59538", + "reference": "d6211c46213d4181054b3d77b10a5c5cb0d59538", "shasum": "" }, "require": { "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.0.4" + "phpstan/phpstan": "^2.1.29" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.2", @@ -9168,22 +10461,22 @@ "description": "Extra strict and opinionated rules for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", - "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.4" + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.7" }, - "time": "2025-03-18T11:42:40+00:00" + "time": "2025-09-26T11:19:08+00:00" }, { "name": "phpstan/phpstan-symfony", - "version": "2.0.6", + "version": "2.0.8", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-symfony.git", - "reference": "5005288e07583546ea00b52de4a9ac412eb869d7" + "reference": "8820c22d785c235f69bb48da3d41e688bc8a1796" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/5005288e07583546ea00b52de4a9ac412eb869d7", - "reference": "5005288e07583546ea00b52de4a9ac412eb869d7", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/8820c22d785c235f69bb48da3d41e688bc8a1796", + "reference": "8820c22d785c235f69bb48da3d41e688bc8a1796", "shasum": "" }, "require": { @@ -9239,9 +10532,9 @@ "description": "Symfony Framework extensions and rules for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-symfony/issues", - "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.6" + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.8" }, - "time": "2025-05-14T07:00:05+00:00" + "time": "2025-09-07T06:55:50+00:00" }, { "name": "phpstan/phpstan-webmozart-assert", @@ -9296,34 +10589,34 @@ }, { "name": "phpunit/php-code-coverage", - "version": "12.3.1", + "version": "12.4.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "ddec29dfc128eba9c204389960f2063f3b7fa170" + "reference": "67e8aed88f93d0e6e1cb7effe1a2dfc2fee6022c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/ddec29dfc128eba9c204389960f2063f3b7fa170", - "reference": "ddec29dfc128eba9c204389960f2063f3b7fa170", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/67e8aed88f93d0e6e1cb7effe1a2dfc2fee6022c", + "reference": "67e8aed88f93d0e6e1cb7effe1a2dfc2fee6022c", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.4.0", + "nikic/php-parser": "^5.6.1", "php": ">=8.3", "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.0", + "sebastian/environment": "^8.0.3", "sebastian/lines-of-code": "^4.0", "sebastian/version": "^6.0", "theseer/tokenizer": "^1.2.3" }, "require-dev": { - "phpunit/phpunit": "^12.1" + "phpunit/phpunit": "^12.3.7" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -9332,7 +10625,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.3.x-dev" + "dev-main": "12.4.x-dev" } }, "autoload": { @@ -9361,7 +10654,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.3.1" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.4.0" }, "funding": [ { @@ -9381,7 +10674,7 @@ "type": "tidelift" } ], - "time": "2025-06-18T08:58:13+00:00" + "time": "2025-09-24T13:44:41+00:00" }, { "name": "phpunit/php-file-iterator", @@ -9630,16 +10923,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.2.6", + "version": "12.4.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "638644c62a58f04974da115f98981c9b48564021" + "reference": "a94ea4d26d865875803b23aaf78c3c2c670ea2ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/638644c62a58f04974da115f98981c9b48564021", - "reference": "638644c62a58f04974da115f98981c9b48564021", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a94ea4d26d865875803b23aaf78c3c2c670ea2ea", + "reference": "a94ea4d26d865875803b23aaf78c3c2c670ea2ea", "shasum": "" }, "require": { @@ -9649,23 +10942,23 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.1", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.3.1", + "phpunit/php-code-coverage": "^12.4.0", "phpunit/php-file-iterator": "^6.0.0", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.0.0", - "sebastian/comparator": "^7.1.0", + "sebastian/cli-parser": "^4.2.0", + "sebastian/comparator": "^7.1.3", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.2", - "sebastian/exporter": "^7.0.0", - "sebastian/global-state": "^8.0.0", + "sebastian/environment": "^8.0.3", + "sebastian/exporter": "^7.0.2", + "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", - "sebastian/type": "^6.0.2", + "sebastian/type": "^6.0.3", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" }, @@ -9675,7 +10968,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.2-dev" + "dev-main": "12.4-dev" } }, "autoload": { @@ -9707,7 +11000,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.2.6" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.4.2" }, "funding": [ { @@ -9731,7 +11024,7 @@ "type": "tidelift" } ], - "time": "2025-07-04T06:00:16+00:00" + "time": "2025-10-30T08:41:39+00:00" }, { "name": "react/cache", @@ -10030,23 +11323,23 @@ }, { "name": "react/promise", - "version": "v3.2.0", + "version": "v3.3.0", "source": { "type": "git", "url": "https://github.com/reactphp/promise.git", - "reference": "8a164643313c71354582dc850b42b33fa12a4b63" + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/8a164643313c71354582dc850b42b33fa12a4b63", - "reference": "8a164643313c71354582dc850b42b33fa12a4b63", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", "shasum": "" }, "require": { "php": ">=7.1.0" }, "require-dev": { - "phpstan/phpstan": "1.10.39 || 1.4.10", + "phpstan/phpstan": "1.12.28 || 1.4.10", "phpunit/phpunit": "^9.6 || ^7.5" }, "type": "library", @@ -10091,7 +11384,7 @@ ], "support": { "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v3.2.0" + "source": "https://github.com/reactphp/promise/tree/v3.3.0" }, "funding": [ { @@ -10099,7 +11392,7 @@ "type": "open_collective" } ], - "time": "2024-05-24T10:39:05+00:00" + "time": "2025-08-19T18:57:03+00:00" }, { "name": "react/socket", @@ -10259,18 +11552,210 @@ ], "time": "2024-06-11T12:45:25+00:00" }, + { + "name": "rector/rector", + "version": "2.2.7", + "source": { + "type": "git", + "url": "https://github.com/rectorphp/rector.git", + "reference": "022038537838bc8a4e526af86c2d6e38eaeff7ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/022038537838bc8a4e526af86c2d6e38eaeff7ef", + "reference": "022038537838bc8a4e526af86c2d6e38eaeff7ef", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "phpstan/phpstan": "^2.1.26" + }, + "conflict": { + "rector/rector-doctrine": "*", + "rector/rector-downgrade-php": "*", + "rector/rector-phpunit": "*", + "rector/rector-symfony": "*" + }, + "suggest": { + "ext-dom": "To manipulate phpunit.xml via the custom-rule command" + }, + "bin": [ + "bin/rector" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Instant Upgrade and Automated Refactoring of any PHP code", + "homepage": "https://getrector.com/", + "keywords": [ + "automation", + "dev", + "migration", + "refactoring" + ], + "support": { + "issues": "https://github.com/rectorphp/rector/issues", + "source": "https://github.com/rectorphp/rector/tree/2.2.7" + }, + "funding": [ + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "time": "2025-10-29T15:46:12+00:00" + }, + { + "name": "sanmai/later", + "version": "0.1.7", + "source": { + "type": "git", + "url": "https://github.com/sanmai/later.git", + "reference": "72a82d783864bca90412d8a26c1878f8981fee97" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sanmai/later/zipball/72a82d783864bca90412d8a26c1878f8981fee97", + "reference": "72a82d783864bca90412d8a26c1878f8981fee97", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.8", + "friendsofphp/php-cs-fixer": "^3.35.1", + "infection/infection": ">=0.27.6", + "phan/phan": ">=2", + "php-coveralls/php-coveralls": "^2.0", + "phpstan/phpstan": ">=1.4.5", + "phpunit/phpunit": ">=9.5 <10", + "vimeo/psalm": ">=2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.1.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Later\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Alexey Kopytko", + "email": "alexey@kopytko.com" + } + ], + "description": "Later: deferred wrapper object", + "support": { + "issues": "https://github.com/sanmai/later/issues", + "source": "https://github.com/sanmai/later/tree/0.1.7" + }, + "funding": [ + { + "url": "https://github.com/sanmai", + "type": "github" + } + ], + "time": "2025-05-11T01:48:00+00:00" + }, + { + "name": "sanmai/pipeline", + "version": "6.22", + "source": { + "type": "git", + "url": "https://github.com/sanmai/pipeline.git", + "reference": "fb8d0c23b4ef085315a36d397fafa052203020ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sanmai/pipeline/zipball/fb8d0c23b4ef085315a36d397fafa052203020ce", + "reference": "fb8d0c23b4ef085315a36d397fafa052203020ce", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.8", + "esi/phpunit-coverage-check": ">2", + "friendsofphp/php-cs-fixer": "^3.17", + "infection/infection": ">=0.30.3", + "league/pipeline": "^0.3 || ^1.0", + "php-coveralls/php-coveralls": "^2.4.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": ">=9.4 <12", + "sanmai/phpstan-rules": "^0.3.0", + "sanmai/phpunit-double-colon-syntax": "^0.1.1", + "vimeo/psalm": ">=2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "v6.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Pipeline\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Alexey Kopytko", + "email": "alexey@kopytko.com" + } + ], + "description": "General-purpose collections pipeline", + "support": { + "issues": "https://github.com/sanmai/pipeline/issues", + "source": "https://github.com/sanmai/pipeline/tree/6.22" + }, + "funding": [ + { + "url": "https://github.com/sanmai", + "type": "github" + } + ], + "time": "2025-07-22T09:07:07+00:00" + }, { "name": "sebastian/cli-parser", - "version": "4.0.0", + "version": "4.2.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "6d584c727d9114bcdc14c86711cd1cad51778e7c" + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/6d584c727d9114bcdc14c86711cd1cad51778e7c", - "reference": "6d584c727d9114bcdc14c86711cd1cad51778e7c", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", "shasum": "" }, "require": { @@ -10282,7 +11767,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "4.2-dev" } }, "autoload": { @@ -10306,28 +11791,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" } ], - "time": "2025-02-07T04:53:50+00:00" + "time": "2025-09-14T09:36:45+00:00" }, { "name": "sebastian/comparator", - "version": "7.1.0", + "version": "7.1.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "03d905327dccc0851c9a08d6a979dfc683826b6f" + "reference": "dc904b4bb3ab070865fa4068cd84f3da8b945148" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/03d905327dccc0851c9a08d6a979dfc683826b6f", - "reference": "03d905327dccc0851c9a08d6a979dfc683826b6f", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/dc904b4bb3ab070865fa4068cd84f3da8b945148", + "reference": "dc904b4bb3ab070865fa4068cd84f3da8b945148", "shasum": "" }, "require": { @@ -10386,7 +11883,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.0" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.3" }, "funding": [ { @@ -10406,7 +11903,7 @@ "type": "tidelift" } ], - "time": "2025-06-17T07:41:58+00:00" + "time": "2025-08-20T11:27:00+00:00" }, { "name": "sebastian/complexity", @@ -10535,16 +12032,16 @@ }, { "name": "sebastian/environment", - "version": "8.0.2", + "version": "8.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "d364b9e5d0d3b18a2573351a1786fbf96b7e0792" + "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/d364b9e5d0d3b18a2573351a1786fbf96b7e0792", - "reference": "d364b9e5d0d3b18a2573351a1786fbf96b7e0792", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", "shasum": "" }, "require": { @@ -10587,7 +12084,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.2" + "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3" }, "funding": [ { @@ -10607,20 +12104,20 @@ "type": "tidelift" } ], - "time": "2025-05-21T15:05:44+00:00" + "time": "2025-08-12T14:11:56+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.0", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "76432aafc58d50691a00d86d0632f1217a47b688" + "reference": "016951ae10980765e4e7aee491eb288c64e505b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/76432aafc58d50691a00d86d0632f1217a47b688", - "reference": "76432aafc58d50691a00d86d0632f1217a47b688", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", + "reference": "016951ae10980765e4e7aee491eb288c64e505b7", "shasum": "" }, "require": { @@ -10677,28 +12174,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2025-02-07T04:56:42+00:00" + "time": "2025-09-24T06:16:11+00:00" }, { "name": "sebastian/global-state", - "version": "8.0.0", + "version": "8.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "570a2aeb26d40f057af686d63c4e99b075fb6cbc" + "reference": "ef1377171613d09edd25b7816f05be8313f9115d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/570a2aeb26d40f057af686d63c4e99b075fb6cbc", - "reference": "570a2aeb26d40f057af686d63c4e99b075fb6cbc", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d", "shasum": "" }, "require": { @@ -10739,15 +12248,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" } ], - "time": "2025-02-07T04:56:59+00:00" + "time": "2025-08-29T11:29:25+00:00" }, { "name": "sebastian/lines-of-code", @@ -10923,16 +12444,16 @@ }, { "name": "sebastian/recursion-context", - "version": "7.0.0", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "c405ae3a63e01b32eb71577f8ec1604e39858a7c" + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/c405ae3a63e01b32eb71577f8ec1604e39858a7c", - "reference": "c405ae3a63e01b32eb71577f8ec1604e39858a7c", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", "shasum": "" }, "require": { @@ -10975,28 +12496,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2025-02-07T05:00:01+00:00" + "time": "2025-08-13T04:44:59+00:00" }, { "name": "sebastian/type", - "version": "6.0.2", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "1d7cd6e514384c36d7a390347f57c385d4be6069" + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/1d7cd6e514384c36d7a390347f57c385d4be6069", - "reference": "1d7cd6e514384c36d7a390347f57c385d4be6069", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", "shasum": "" }, "require": { @@ -11032,15 +12565,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.2" + "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2025-03-18T13:37:31+00:00" + "time": "2025-08-09T06:57:12+00:00" }, { "name": "sebastian/version", @@ -11150,16 +12695,16 @@ }, { "name": "symfony/browser-kit", - "version": "v7.3.0", + "version": "v7.3.2", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "5384291845e74fd7d54f3d925c4a86ce12336593" + "reference": "f0b889b73a845cddef1d25fe207b37fd04cb5419" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/5384291845e74fd7d54f3d925c4a86ce12336593", - "reference": "5384291845e74fd7d54f3d925c4a86ce12336593", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/f0b889b73a845cddef1d25fe207b37fd04cb5419", + "reference": "f0b889b73a845cddef1d25fe207b37fd04cb5419", "shasum": "" }, "require": { @@ -11198,7 +12743,7 @@ "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/browser-kit/tree/v7.3.0" + "source": "https://github.com/symfony/browser-kit/tree/v7.3.2" }, "funding": [ { @@ -11209,12 +12754,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-05T10:15:41+00:00" + "time": "2025-07-10T08:47:49+00:00" }, { "name": "symfony/css-selector", @@ -11283,16 +12832,16 @@ }, { "name": "symfony/debug-bundle", - "version": "v7.3.0", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/debug-bundle.git", - "reference": "781acc90f31f5fe18915f9276890864ebbbe3da8" + "reference": "0aee008fb501677fa5b62ea5f65cabcf041629ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/781acc90f31f5fe18915f9276890864ebbbe3da8", - "reference": "781acc90f31f5fe18915f9276890864ebbbe3da8", + "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/0aee008fb501677fa5b62ea5f65cabcf041629ef", + "reference": "0aee008fb501677fa5b62ea5f65cabcf041629ef", "shasum": "" }, "require": { @@ -11334,7 +12883,7 @@ "description": "Provides a tight integration of the Symfony VarDumper component and the ServerLogCommand from MonologBridge into the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/debug-bundle/tree/v7.3.0" + "source": "https://github.com/symfony/debug-bundle/tree/v7.3.5" }, "funding": [ { @@ -11345,25 +12894,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-04T13:21:13+00:00" + "time": "2025-10-13T11:49:56+00:00" }, { "name": "symfony/dom-crawler", - "version": "v7.3.1", + "version": "v7.3.3", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "8b2ee2e06ab99fa5f067b6699296d4e35c156bb9" + "reference": "efa076ea0eeff504383ff0dcf827ea5ce15690ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/8b2ee2e06ab99fa5f067b6699296d4e35c156bb9", - "reference": "8b2ee2e06ab99fa5f067b6699296d4e35c156bb9", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/efa076ea0eeff504383ff0dcf827ea5ce15690ba", + "reference": "efa076ea0eeff504383ff0dcf827ea5ce15690ba", "shasum": "" }, "require": { @@ -11401,7 +12954,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v7.3.1" + "source": "https://github.com/symfony/dom-crawler/tree/v7.3.3" }, "funding": [ { @@ -11412,12 +12965,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-15T10:07:06+00:00" + "time": "2025-08-06T20:13:54+00:00" }, { "name": "symfony/maker-bundle", @@ -11514,16 +13071,16 @@ }, { "name": "symfony/web-profiler-bundle", - "version": "v7.3.1", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "47c994d8f08817122ffb48bf2ea4fb97b7e00d51" + "reference": "c2ed11cc0e9093fe0425ad52498d26a458842e0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/47c994d8f08817122ffb48bf2ea4fb97b7e00d51", - "reference": "47c994d8f08817122ffb48bf2ea4fb97b7e00d51", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/c2ed11cc0e9093fe0425ad52498d26a458842e0c", + "reference": "c2ed11cc0e9093fe0425ad52498d26a458842e0c", "shasum": "" }, "require": { @@ -11579,7 +13136,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.3.1" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v7.3.5" }, "funding": [ { @@ -11590,12 +13147,155 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-05T09:30:41+00:00" + "time": "2025-10-06T13:36:11+00:00" + }, + { + "name": "thecodingmachine/safe", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/2cdd579eeaa2e78e51c7509b50cc9fb89a956236", + "reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2025-05-14T06:15:44+00:00" }, { "name": "theseer/tokenizer", diff --git a/config/bundles.php b/config/bundles.php index 1cd8c80..a7d106b 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -1,5 +1,7 @@ ['all' => true], Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], @@ -15,4 +17,8 @@ Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Symfonycasts\SassBundle\SymfonycastsSassBundle::class => ['all' => true], Symfony\UX\Chartjs\ChartjsBundle::class => ['all' => true], + Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], + Symfony\UX\LiveComponent\LiveComponentBundle::class => ['all' => true], + Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], + Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], ]; diff --git a/config/packages/mercure.yaml b/config/packages/mercure.yaml new file mode 100644 index 0000000..f2a7395 --- /dev/null +++ b/config/packages/mercure.yaml @@ -0,0 +1,8 @@ +mercure: + hubs: + default: + url: '%env(MERCURE_URL)%' + public_url: '%env(MERCURE_PUBLIC_URL)%' + jwt: + secret: '%env(MERCURE_JWT_SECRET)%' + publish: '*' diff --git a/config/packages/twig_component.yaml b/config/packages/twig_component.yaml new file mode 100644 index 0000000..81ddc53 --- /dev/null +++ b/config/packages/twig_component.yaml @@ -0,0 +1,6 @@ +twig_component: + anonymous_template_directory: 'components/' + defaults: + # Namespace & directory for components + App\Twig\Components\: 'components/' + Jblairy\PhpBenchmark\Infrastructure\Web\Component\: 'components/' diff --git a/config/routes/ux_live_component.yaml b/config/routes/ux_live_component.yaml new file mode 100644 index 0000000..e56523a --- /dev/null +++ b/config/routes/ux_live_component.yaml @@ -0,0 +1,5 @@ +live_component: + resource: '@LiveComponentBundle/config/routes.php' + prefix: '/_components' + # adjust prefix to add localization to your components + #prefix: '/{_locale}/_components' diff --git a/config/services.yaml b/config/services.yaml index e9e0ea0..8e696b9 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -12,11 +12,6 @@ services: autowire: true # Automatically injects dependencies in your services. autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. - # Auto-tag all Benchmark implementations for autowiring - _instanceof: - Jblairy\PhpBenchmark\Domain\Benchmark\Contract\Benchmark: - tags: ['Jblairy\PhpBenchmark\Domain\Benchmark\Contract\Benchmark'] - # makes classes in src/ available to be used as services Jblairy\PhpBenchmark\: resource: '../src/' @@ -25,18 +20,16 @@ services: - '../src/Domain/' - '../src/Kernel.php' - Jblairy\PhpBenchmark\Domain\Benchmark\Test\: - resource: '../src/Domain/Benchmark/Test/' - # Clean Architecture - Port implementations # Code Extraction (Port -> Adapter) + # Uses DatabaseCodeExtractor to support YAML fixtures from database Jblairy\PhpBenchmark\Domain\Benchmark\Port\CodeExtractorPort: - class: Jblairy\PhpBenchmark\Infrastructure\Execution\CodeExtraction\ReflectionCodeExtractor + class: Jblairy\PhpBenchmark\Infrastructure\Execution\CodeExtraction\DatabaseCodeExtractor # Benchmark Repository (Port -> Adapter) Jblairy\PhpBenchmark\Domain\Benchmark\Port\BenchmarkRepositoryPort: - class: Jblairy\PhpBenchmark\Infrastructure\Persistence\InMemory\InMemoryBenchmarkRepository + class: Jblairy\PhpBenchmark\Infrastructure\Persistence\Doctrine\Repository\DoctrineBenchmarkRepository # Script Execution (Port -> Adapter) Jblairy\PhpBenchmark\Domain\Benchmark\Port\ScriptExecutorPort: @@ -45,12 +38,38 @@ services: # Result Persistence (Port -> Adapter) Jblairy\PhpBenchmark\Domain\Benchmark\Port\ResultPersisterPort: class: Jblairy\PhpBenchmark\Infrastructure\Persistence\Doctrine\DoctrinePulseResultPersister + + # Pulse Repository (Port -> Adapter) + Jblairy\PhpBenchmark\Domain\Dashboard\Port\PulseRepositoryPort: + class: Jblairy\PhpBenchmark\Infrastructure\Persistence\Doctrine\Repository\PulseRepository # Benchmark Execution (Port -> Adapter) Jblairy\PhpBenchmark\Domain\Benchmark\Port\BenchmarkExecutorPort: class: Jblairy\PhpBenchmark\Domain\Benchmark\Service\SingleBenchmarkExecutor - # Application Use Cases with dependencies - Jblairy\PhpBenchmark\Application\UseCase\AsyncBenchmarkRunner: + # Script Builder (Port -> Adapter) + Jblairy\PhpBenchmark\Domain\Benchmark\Port\ScriptBuilderPort: + class: Jblairy\PhpBenchmark\Infrastructure\Execution\ScriptBuilding\InstrumentedScriptBuilder + + # Event Dispatcher (Port -> Adapter) + Jblairy\PhpBenchmark\Domain\Benchmark\Port\EventDispatcherPort: + class: Jblairy\PhpBenchmark\Infrastructure\Async\SymfonyEventDispatcherAdapter + + # Async Executor (Port -> Adapter) + Jblairy\PhpBenchmark\Domain\Benchmark\Port\AsyncExecutorPort: + class: Jblairy\PhpBenchmark\Infrastructure\Async\SpatieAsyncExecutorAdapter arguments: $concurrency: 100 + + # Benchmark auto-tagging (moved from Domain to keep it framework-agnostic) + _instanceof: + Jblairy\PhpBenchmark\Domain\Benchmark\Contract\Benchmark: + tags: ['app.benchmark'] + + # Domain Services (need explicit registration since Domain is excluded) + Jblairy\PhpBenchmark\Domain\Dashboard\Service\StatisticsCalculator: ~ + + # Fixtures configuration + Jblairy\PhpBenchmark\Infrastructure\Persistence\Doctrine\Fixtures\YamlBenchmarkFixtures: + arguments: + $projectDir: '%kernel.project_dir%' diff --git a/docker-compose.yml b/docker-compose.yml index a5f1de8..ccb1d73 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,12 @@ services: environment: - DOCKER_HOST=unix:///var/run/docker.sock - DATABASE_URL=mysql://root:password@mariadb:3306/php_benchmark + - MERCURE_URL=http://mercure/.well-known/mercure + - MERCURE_PUBLIC_URL=http://localhost:3000/.well-known/mercure + - MERCURE_JWT_SECRET=!ChangeThisMercureHubJWTSecretKey! depends_on: - mariadb + - mercure command: php -S 0.0.0.0:8000 -t public ports: - "8000:8000" @@ -30,6 +34,25 @@ services: - mariadb_data:/var/lib/mysql command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + ###> symfony/mercure-bundle ### + mercure: + image: dunglas/mercure + restart: unless-stopped + environment: + SERVER_NAME: ':80' + MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' + MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' + MERCURE_EXTRA_DIRECTIVES: | + cors_origins http://localhost:8000 http://127.0.0.1:8000 + anonymous + heartbeat 15s + command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile + ports: + - "3000:80" + volumes: + - mercure_data:/data + - mercure_config:/config + ###< symfony/mercure-bundle ### php56: image: php:5.6-cli @@ -143,3 +166,7 @@ services: volumes: mariadb_data: +###> symfony/mercure-bundle ### + mercure_data: + mercure_config: +###< symfony/mercure-bundle ### diff --git a/docs/README.md b/docs/README.md index 5e79c07..9035d7a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,13 +11,21 @@ 2. **[architecture/02-layers.md](architecture/02-layers.md)** - Domain, Application, Infrastructure layers 3. **[architecture/03-ports-adapters.md](architecture/03-ports-adapters.md)** - Ports & Adapters pattern -## 💡 Concepts +## 🐳 Infrastructure + +- **[infrastructure/docker.md](infrastructure/docker.md)** - Docker architecture, services, and execution flow +- **[infrastructure/mercure-index.md](infrastructure/mercure-index.md)** - **⭐ Mercure real-time guide (START HERE)** + - [mercure-realtime.md](infrastructure/mercure-realtime.md) - Architecture & Configuration + - [mercure-practical-guide.md](infrastructure/mercure-practical-guide.md) - Debugging & Usage + - [mercure-implementation-summary.md](infrastructure/mercure-implementation-summary.md) - Implementation Summary -- **[concepts/value-objects-vs-entities.md](concepts/value-objects-vs-entities.md)** - DDD patterns explained +## 💡 Concepts ## 📖 Guides - **[guides/creating-benchmarks.md](guides/creating-benchmarks.md)** - How to create benchmarks +- **[guides/fixtures.md](guides/fixtures.md)** - **⭐ Benchmark fixtures system (YAML)** +- **[guides/atomic-commits.md](guides/atomic-commits.md)** - **⭐ Git atomic commits best practices** ## 🔧 Tools diff --git a/docs/adr/001-hexagonal-architecture.md b/docs/adr/001-hexagonal-architecture.md new file mode 100644 index 0000000..bbb3efb --- /dev/null +++ b/docs/adr/001-hexagonal-architecture.md @@ -0,0 +1,82 @@ +# ADR-001: Hexagonal Architecture (Ports & Adapters) + +**Status**: Accepted +**Date**: 2024-08-26 +**Deciders**: Development Team + +## Context + +PHP Benchmark is a web application for comparing PHP performance across methods and versions. The application needs to: + +- Execute benchmarks in isolated Docker containers +- Store and retrieve benchmark results from a database +- Display real-time progress updates via WebSockets +- Load fixtures from YAML files +- Present data through a web interface + +Without a clear architectural structure, business logic would become entangled with framework code (Symfony), database access (Doctrine), and external services (Docker, Mercure). This makes testing difficult, reduces maintainability, and creates tight coupling to specific technologies. + +## Decision + +We adopt **Hexagonal Architecture** (also known as Ports & Adapters) with three layers: + +### 1. Domain Layer (`src/Domain/`) +- **Pure PHP business logic** with zero framework dependencies +- Contains entities, value objects, domain services, and exceptions +- Defines **Ports** (interfaces) for external interactions +- Example: `BenchmarkRepositoryPort`, `BenchmarkExecutorPort` + +### 2. Application Layer (`src/Application/`) +- Orchestrates use cases and workflows +- Uses Domain services and Ports +- Contains DTOs for data transfer between layers +- Minimal dependencies (Domain only) + +### 3. Infrastructure Layer (`src/Infrastructure/`) +- Implements **Adapters** for Domain Ports +- Contains framework-specific code (Symfony controllers, Doctrine entities, CLI commands) +- Handles external integrations (Docker, Mercure, filesystem) +- Example: `DoctrineBenchmarkRepository` implements `BenchmarkRepositoryPort` + +### Dependency Rule +Dependencies flow **inward only**: Infrastructure → Application → Domain + +``` +┌─────────────────────────────────────┐ +│ Infrastructure Layer │ +│ (Symfony, Doctrine, Docker, CLI) │ +│ implements Ports ↓ │ +├─────────────────────────────────────┤ +│ Application Layer │ +│ (Use Cases, Orchestration) │ +│ uses Domain ↓ │ +├─────────────────────────────────────┤ +│ Domain Layer │ +│ (Business Logic, Entities, VOs) │ +│ defines Ports ↑ │ +└─────────────────────────────────────┘ +``` + +## Consequences + +### Positive +- **Testability**: Domain logic can be unit tested without mocks or database +- **Maintainability**: Business rules are isolated from technical concerns +- **Flexibility**: Easy to swap implementations (e.g., switch from Doctrine to another ORM) +- **Clear boundaries**: Developers know where to place new code +- **Framework independence**: Domain logic survives framework changes + +### Negative +- **Initial complexity**: More files and abstractions than a simple MVC approach +- **Learning curve**: Team members must understand layered architecture +- **Boilerplate**: Ports and Adapters require interface definitions + +### Trade-offs Accepted +- We accept increased file count for improved separation of concerns +- We accept some duplication (Domain entities + Doctrine entities) for framework independence +- We prioritize long-term maintainability over short-term development speed + +## References +- [Clean Architecture by Robert C. Martin](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) +- [Hexagonal Architecture by Alistair Cockburn](https://alistair.cockburn.us/hexagonal-architecture/) +- Project documentation: `docs/architecture/03-ports-adapters.md` diff --git a/docs/adr/002-symfony-validator-for-fixtures.md b/docs/adr/002-symfony-validator-for-fixtures.md new file mode 100644 index 0000000..92ed3b6 --- /dev/null +++ b/docs/adr/002-symfony-validator-for-fixtures.md @@ -0,0 +1,103 @@ +# ADR-002: Use Symfony Validator for YAML Fixtures + +**Status**: Accepted +**Date**: 2024-08-26 +**Deciders**: Development Team + +## Context + +The application loads benchmark definitions from YAML files in `fixtures/benchmarks/*.yaml`. Each fixture contains: + +```yaml +name: "Loop Comparison" +category: "Control Structures" +icon: "🔄" +description: "Compare for vs foreach performance" +methods: + - name: "For Loop" + code: "for ($i = 0; $i < 1000; $i++) {}" +``` + +We need to validate: +- Required fields are present (name, category, methods) +- Data types are correct (strings, arrays) +- Constraints are met (max lengths, non-empty arrays) +- Business rules (unique method names, valid icon format) + +### Options Considered + +1. **Manual validation** with if/else checks in fixture loader +2. **Custom validation classes** with dedicated validator objects +3. **Symfony Validator** using attributes/annotations +4. **JSON Schema** with external validation library + +## Decision + +We use **Symfony Validator** with constraint attributes for several reasons: + +1. **Already installed**: Symfony Validator is part of our Symfony stack +2. **Declarative syntax**: Constraints are defined as PHP attributes directly on DTOs +3. **Rich constraint library**: Built-in constraints for common validations (NotBlank, Length, Type, Count) +4. **Clear error messages**: Automatic generation of human-readable validation errors +5. **Reusable**: Same validation logic can be used for API inputs, forms, and fixtures + +### Implementation + +```php +final readonly class BenchmarkFixtureData +{ + public function __construct( + #[Assert\NotBlank] + #[Assert\Length(max: 255)] + public string $name, + + #[Assert\NotBlank] + #[Assert\Length(max: 100)] + public string $category, + + #[Assert\Count(min: 2)] + #[Assert\All([ + new Assert\Type(MethodFixtureData::class), + ])] + public array $methods, + ) {} +} +``` + +Validation happens in `YamlBenchmarkFixtures::validateFixtureData()` which throws `ValidationException` with detailed error messages. + +## Consequences + +### Positive +- **Reduced boilerplate**: No need to write manual if/else validation chains +- **Self-documenting**: Constraints make validation rules explicit and discoverable +- **Consistency**: Same validation approach across application (forms, API, fixtures) +- **Type safety**: Combined with PHP 8.4 strict types, catches errors early +- **Error messages**: Automatic generation of detailed validation feedback + +### Negative +- **Framework coupling**: Fixture validation depends on Symfony Validator +- **Attribute verbosity**: Multiple attributes per property can be verbose +- **Limited customization**: Complex business rules may need custom constraints + +### Trade-offs Accepted +- We accept Symfony dependency in Infrastructure layer (aligned with Hexagonal Architecture) +- We accept attribute verbosity for clarity and explicitness +- For complex validations, we supplement with custom validation methods + +## Alternatives Not Chosen + +### Manual Validation +```php +if (empty($data['name'])) { + throw new \RuntimeException('Name is required'); +} +``` +**Rejected**: Too verbose, error-prone, hard to maintain + +### JSON Schema +**Rejected**: Requires external library, separate schema files to maintain, less IDE support + +## References +- [Symfony Validator Documentation](https://symfony.com/doc/current/validation.html) +- Implementation: `src/Infrastructure/Persistence/Doctrine/Fixtures/YamlBenchmarkFixtures.php` diff --git a/docs/adr/003-mercure-for-realtime.md b/docs/adr/003-mercure-for-realtime.md new file mode 100644 index 0000000..73a7926 --- /dev/null +++ b/docs/adr/003-mercure-for-realtime.md @@ -0,0 +1,130 @@ +# ADR-003: Use Mercure for Real-Time Updates + +**Status**: Accepted +**Date**: 2024-08-28 +**Deciders**: Development Team + +## Context + +Benchmark execution can take several seconds to minutes (1000+ iterations per method). Users need real-time feedback showing: + +- Progress percentage (0-100%) +- Current iteration count +- Estimated time remaining +- Live status updates (running, completed, failed) + +Without real-time updates, users would see a blank screen or spinner, leading to poor user experience and uncertainty about execution status. + +### Requirements +- **Server-to-client push**: Server must push updates without client polling +- **Low latency**: Updates should appear within milliseconds +- **Symfony integration**: Must work seamlessly with Symfony ecosystem +- **Scalability**: Support multiple concurrent benchmark executions + +### Options Considered + +1. **HTTP Polling**: Client requests updates every N seconds +2. **WebSockets**: Full-duplex persistent connection (Socket.io, Ratchet) +3. **Server-Sent Events (SSE)**: Unidirectional server-to-client stream +4. **Mercure**: SSE-based protocol with Symfony integration + +## Decision + +We use **Mercure** (SSE-based protocol) for real-time updates because: + +1. **Native Symfony integration**: Official `symfony/mercure-bundle` with minimal configuration +2. **Simpler than WebSockets**: Unidirectional communication (server → client) sufficient for our use case +3. **HTTP/2 compatible**: Works over standard HTTP, no protocol upgrade needed +4. **Built-in reconnection**: Automatic reconnection and event replay on disconnect +5. **Topic-based subscriptions**: Clients subscribe to specific benchmark updates via URLs +6. **Production-ready**: Standalone Mercure Hub (Go binary) handles thousands of connections + +### Architecture + +``` +PHP Application (Symfony) + ↓ publishes updates +Mercure Hub (:3000) + ↓ broadcasts via SSE +Browser (EventSource) + ↓ updates UI (Stimulus) +``` + +### Implementation + +**Server-side** (`BenchmarkProgressPublisher`): +```php +$this->hub->publish(new Update( + topics: ["http://app/benchmark/{$id}"], + data: json_encode(['progress' => 45, 'iteration' => 450]) +)); +``` + +**Client-side** (`mercure-progress_controller.js`): +```javascript +const url = new URL(mercureUrl); +url.searchParams.append('topic', `http://app/benchmark/${id}`); +const eventSource = new EventSource(url); +eventSource.onmessage = (e) => updateProgress(JSON.parse(e.data)); +``` + +## Consequences + +### Positive +- **Real-time UX**: Users see live progress without refresh +- **Efficient**: No polling overhead, server pushes only when data changes +- **Resilient**: Auto-reconnection and event replay prevent data loss +- **Developer-friendly**: Simple publish/subscribe API +- **Scalable**: Mercure Hub handles connections independently from PHP processes + +### Negative +- **Additional service**: Requires running Mercure Hub (Docker container in our case) +- **Network dependency**: Real-time updates fail if Mercure Hub is down +- **Browser support**: Older browsers may not support EventSource (IE11) +- **Debugging**: SSE connections harder to inspect than HTTP requests + +### Trade-offs Accepted +- We accept operational overhead of running Mercure Hub for improved UX +- We accept SSE limitations (unidirectional) since we don't need client-to-server streaming +- We provide fallback: polling mechanism if EventSource unavailable (not implemented yet) + +## Alternatives Not Chosen + +### HTTP Polling +```javascript +setInterval(() => fetch('/benchmark/status'), 2000); +``` +**Rejected**: Inefficient (wasted requests when no updates), higher latency, server load + +### WebSockets (Socket.io/Ratchet) +**Rejected**: Overkill for unidirectional updates, more complex setup, requires protocol upgrade + +### Server-Sent Events (Raw SSE) +**Rejected**: Would need to implement reconnection, event replay, and topic routing ourselves + +## Configuration + +```yaml +# config/packages/mercure.yaml +mercure: + hubs: + default: + url: '%env(MERCURE_URL)%' + public_url: '%env(MERCURE_PUBLIC_URL)%' + jwt_secret: '%env(MERCURE_JWT_SECRET)%' +``` + +```yaml +# docker-compose.yml +mercure: + image: dunglas/mercure + environment: + MERCURE_PUBLISHER_JWT_KEY: 'your-secret-key' + MERCURE_SUBSCRIBER_JWT_KEY: 'your-secret-key' +``` + +## References +- [Mercure Protocol Specification](https://mercure.rocks/) +- [Symfony Mercure Bundle](https://symfony.com/doc/current/mercure.html) +- Project implementation: `docs/infrastructure/mercure-index.md` +- Testing scripts: `scripts/mercure-test.sh`, `scripts/mercure-verify.sh` diff --git a/docs/adr/004-docker-for-benchmark-isolation.md b/docs/adr/004-docker-for-benchmark-isolation.md new file mode 100644 index 0000000..0db82c0 --- /dev/null +++ b/docs/adr/004-docker-for-benchmark-isolation.md @@ -0,0 +1,146 @@ +# ADR-004: Use Docker for Benchmark Execution Isolation + +**Status**: Accepted +**Date**: 2024-08-26 +**Deciders**: Development Team + +## Context + +The application compares PHP performance across: +- **Multiple PHP versions** (8.0, 8.1, 8.2, 8.3, 8.4, 8.5) +- **Different methods** (e.g., `for` vs `foreach`, `array_map` vs manual loops) + +### Challenges + +1. **Version isolation**: Need to run benchmarks on different PHP versions without conflicts +2. **Consistent environment**: CPU, memory, and OS must be identical for fair comparison +3. **Reproducibility**: Same benchmark must produce consistent results across runs +4. **Security**: User-provided code (YAML fixtures) must run in isolated environment +5. **Scalability**: Must support concurrent benchmark execution + +### Options Considered + +1. **Native execution**: Run benchmarks directly on host PHP +2. **PHP version managers** (phpenv, phpbrew): Switch PHP versions +3. **Virtual machines**: Separate VM per PHP version +4. **Docker containers**: Containerized PHP environments + +## Decision + +We use **Docker containers** with dedicated images per PHP version because: + +1. **Complete isolation**: Each benchmark runs in separate container with own filesystem, network, processes +2. **Version flexibility**: Easy to add new PHP versions by creating new Dockerfile +3. **Reproducible environment**: Container images ensure identical environment every run +4. **Resource control**: Can limit CPU/memory per container to prevent resource exhaustion +5. **Security**: Containers provide process-level isolation from host system +6. **Developer experience**: Same environment for development and production + +### Architecture + +``` +Host (Symfony App) + ↓ executes via DockerBenchmarkExecutor +Docker Engine + ↓ spawns containers + ├─ php84 container (FROM php:8.4-cli) + ├─ php85 container (FROM php:8.5-cli) + └─ php83 container (FROM php:8.3-cli) +``` + +### Implementation + +**Dockerfiles** (one per version): +```dockerfile +# Dockerfile.php84 +FROM php:8.4-cli +WORKDIR /app +RUN apt-get update && apt-get install -y git unzip +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer +``` + +**Execution** (`DockerBenchmarkExecutor`): +```php +$command = sprintf( + 'docker run --rm -v %s:/app php84 php /app/benchmark.php', + $benchmarkDir +); +``` + +**Docker Compose** (multi-version setup): +```yaml +services: + php84: + build: + dockerfile: Dockerfile.php84 + volumes: + - ./:/var/www/php_benchmark + + php85: + build: + dockerfile: Dockerfile.php85 + volumes: + - ./:/var/www/php_benchmark +``` + +## Consequences + +### Positive +- **Version independence**: Run PHP 8.0 and 8.5 benchmarks simultaneously +- **Fair comparison**: All benchmarks run in identical environments (same base image) +- **Easy updates**: Update PHP version by changing Dockerfile base image +- **CI/CD ready**: Same Docker setup works in development and CI pipeline +- **Security**: Malicious code contained within container +- **Debugging**: Can inspect container filesystem after execution + +### Negative +- **Overhead**: Container startup adds ~100-200ms per benchmark execution +- **Disk space**: Each PHP version image requires ~400-500MB +- **Complexity**: Requires Docker daemon running, docker-compose configuration +- **Resource usage**: Running multiple containers consumes more memory than native execution + +### Trade-offs Accepted +- We accept startup overhead for isolation and reproducibility +- We accept disk space cost for version flexibility +- We accept operational complexity for security and consistency +- Container overhead is negligible compared to benchmark execution time (seconds) + +## Alternatives Not Chosen + +### Native Execution +```bash +php /path/to/benchmark.php +``` +**Rejected**: Cannot switch PHP versions, no isolation, security risk + +### PHP Version Managers (phpenv) +```bash +phpenv local 8.4.0 +php benchmark.php +``` +**Rejected**: Difficult to manage multiple versions, no process isolation, not CI-friendly + +### Virtual Machines +**Rejected**: Too heavy (GB of disk space), slow startup (minutes), complex setup + +## Performance Considerations + +Benchmark execution time breakdown: +- Container startup: ~150ms +- PHP JIT warmup: ~50ms +- Actual benchmark: 1-10 seconds (1000 iterations) + +Container overhead is **<2%** of total execution time, acceptable for isolation benefits. + +## Future Improvements + +1. **Image caching**: Pre-pull all PHP images to reduce first-run latency +2. **Resource limits**: Add `--cpus` and `--memory` flags for consistent resource allocation +3. **Parallel execution**: Run multiple benchmarks concurrently using container pools +4. **ARM support**: Add multi-arch images for ARM-based systems (Apple Silicon) + +## References +- [Docker Documentation](https://docs.docker.com/) +- [PHP Official Docker Images](https://hub.docker.com/_/php) +- Project implementation: `src/Infrastructure/Execution/DockerBenchmarkExecutor.php` +- Build configuration: `Dockerfile.main`, `Dockerfile.php85`, `docker-compose.yml` diff --git a/docs/adr/005-phpstan-level-max.md b/docs/adr/005-phpstan-level-max.md new file mode 100644 index 0000000..4e64545 --- /dev/null +++ b/docs/adr/005-phpstan-level-max.md @@ -0,0 +1,156 @@ +# ADR-005: Enforce PHPStan Level Max + +**Status**: Accepted +**Date**: 2024-08-26 +**Deciders**: Development Team + +## Context + +PHP is a dynamically typed language, allowing variables to change types at runtime. This flexibility leads to: + +- **Type-related bugs** (passing string to method expecting int) +- **Null pointer exceptions** (calling methods on null) +- **Array key errors** (accessing non-existent array indices) +- **Method signature mismatches** (incorrect parameter types) + +These errors often surface only at runtime, sometimes in production, leading to poor reliability and difficult debugging. + +### Project Requirements +- **Type safety**: Catch type errors during development, not production +- **Refactoring confidence**: Safely change code without breaking existing functionality +- **Documentation**: Code should be self-documenting through type declarations +- **Maintainability**: Prevent future developers from introducing type-unsafe code + +### Options Considered + +1. **No static analysis**: Rely on manual testing and runtime errors +2. **PHPStan Level 5** (default): Basic type checking with reasonable strictness +3. **PHPStan Level 8**: Advanced checks including unused variables +4. **PHPStan Level 9 (Max)**: Strictest analysis, enforces mixed type declarations + +## Decision + +We enforce **PHPStan Level 9 (Max)** with zero errors allowed because: + +1. **Maximum type safety**: Catches every possible type-related issue PHPStan can detect +2. **Explicit mixed types**: Forces developers to acknowledge when types are truly unknown +3. **No assumptions**: PHPStan makes zero assumptions about missing type hints +4. **Refactoring confidence**: Type system guarantees prevent accidental breakage +5. **Future-proof**: Already at highest level, won't need incremental upgrades + +### Configuration + +```neon +# phpstan.dist.neon +parameters: + level: max + paths: + - src + - tests + + checkMissingIterableValueType: true + checkGenericClassInNonGenericObjectType: true + checkBenevolentUnionTypes: true + strictRules: true +``` + +### Requirements for Developers + +All code must: +- Declare **all parameter types** (`function foo(string $bar)`) +- Declare **all return types** (`function foo(): int`) +- Declare **all property types** (`private string $name`) +- Use `mixed` explicitly when type truly unknown (`function foo(mixed $data)`) +- Add `@param` and `@return` PHPDoc for complex types (arrays, generics) + +Example: +```php +final readonly class StatisticsCalculator +{ + /** + * @param array $executionTimes + * @return array{min: float, max: float, avg: float} + */ + public function calculate(array $executionTimes): array + { + return [ + 'min' => min($executionTimes), + 'max' => max($executionTimes), + 'avg' => array_sum($executionTimes) / count($executionTimes), + ]; + } +} +``` + +## Consequences + +### Positive +- **Fewer bugs**: Type errors caught before code reaches production +- **Better IDE support**: Full autocomplete and refactoring tools +- **Self-documenting**: Types serve as always-up-to-date documentation +- **Refactoring confidence**: Change method signatures with guaranteed type safety +- **Code quality**: Forces developers to think about types explicitly +- **CI/CD integration**: Automated type checking in every pull request + +### Negative +- **Initial effort**: Requires adding type hints to all existing code +- **Learning curve**: Developers must understand PHPDoc annotations for complex types +- **False positives**: Occasionally PHPStan reports issues that are actually safe +- **Development friction**: Cannot merge code with type errors, even if functionally correct + +### Trade-offs Accepted +- We accept occasional false positives for overall type safety +- We accept development friction as necessary for quality enforcement +- We use `@phpstan-ignore-next-line` sparingly for unavoidable false positives +- We prioritize long-term maintainability over short-term development speed + +## Alternatives Not Chosen + +### No Static Analysis +**Rejected**: Unacceptable risk of production bugs, poor developer experience + +### PHPStan Level 5-8 +```neon +level: 5 +``` +**Rejected**: Leaves gaps in type safety, would eventually need to upgrade to max anyway + +### Psalm/Phan +**Rejected**: PHPStan has better Symfony integration, larger community, more active development + +## CI/CD Integration + +PHPStan runs automatically on every commit via GitHub Actions: + +```yaml +# .github/workflows/quality.yml +- name: PHPStan + run: vendor/bin/phpstan analyse --no-progress --error-format=github +``` + +Pull requests cannot merge if PHPStan fails. + +## Suppressing False Positives + +When PHPStan incorrectly reports an error (rare), use inline suppression: + +```php +// @phpstan-ignore-next-line argument.type +$result = someThirdPartyLib($value); +``` + +**Rule**: Suppression must include: +1. Specific error identifier (e.g., `argument.type`) +2. Inline comment explaining why suppression needed + +## Future Improvements + +1. **Custom rules**: Add project-specific PHPStan rules (e.g., enforce Port naming convention) +2. **Baseline reduction**: Gradually eliminate any remaining baseline suppressions +3. **Generic annotations**: Leverage PHPStan generics for collection types + +## References +- [PHPStan Documentation](https://phpstan.org/user-guide/rule-levels) +- [Why Level Max?](https://phpstan.org/blog/find-bugs-in-your-code-without-writing-tests) +- Configuration: `phpstan.dist.neon` +- Makefile command: `make phpstan` diff --git a/docs/adr/006-async-executor-and-event-dispatcher-ports.md b/docs/adr/006-async-executor-and-event-dispatcher-ports.md new file mode 100644 index 0000000..842931f --- /dev/null +++ b/docs/adr/006-async-executor-and-event-dispatcher-ports.md @@ -0,0 +1,166 @@ +# 6. Create AsyncExecutorPort and EventDispatcherPort to Decouple External Libraries + +Date: 2025-11-04 + +## Status + +Accepted + +## Context + +The `AsyncBenchmarkRunner` use case in the Application layer was directly depending on external libraries: +- `Symfony\Contracts\EventDispatcher\EventDispatcherInterface` (Framework) +- `Spatie\Async\Pool` (Third-party library) + +This violated Clean Architecture principles: +- Application layer should only depend on Domain, not Infrastructure +- PHPArkitect detected 4 architecture violations +- Code was tightly coupled to specific implementations + +### Detected Violations + +``` +Jblairy\PhpBenchmark\Application\UseCase\AsyncBenchmarkRunner has 4 violations: + - depends on Symfony\Contracts\EventDispatcher\EventDispatcherInterface (line 23) + - depends on Spatie\Async\Pool (line 44) +``` + +## Decision + +We created two new Ports (interfaces) in the Domain layer to abstract these dependencies: + +### 1. EventDispatcherPort + +```php +namespace Jblairy\PhpBenchmark\Domain\Benchmark\Port; + +interface EventDispatcherPort +{ + public function dispatch(object $event): void; +} +``` + +**Adapter:** +- `Infrastructure\Async\SymfonyEventDispatcherAdapter` - Adapts Symfony's EventDispatcher + +### 2. AsyncExecutorPort + +```php +namespace Jblairy\PhpBenchmark\Domain\Benchmark\Port; + +interface AsyncExecutorPort +{ + public function addTask(callable $task, callable $onSuccess): void; + public function wait(): void; +} +``` + +**Adapter:** +- `Infrastructure\Async\SpatieAsyncExecutorAdapter` - Adapts Spatie\Async\Pool + +### Configuration + +Both Ports are configured in `config/services.yaml` with their respective adapters: + +```yaml +Jblairy\PhpBenchmark\Domain\Benchmark\Port\EventDispatcherPort: + class: Jblairy\PhpBenchmark\Infrastructure\Async\SymfonyEventDispatcherAdapter + +Jblairy\PhpBenchmark\Domain\Benchmark\Port\AsyncExecutorPort: + class: Jblairy\PhpBenchmark\Infrastructure\Async\SpatieAsyncExecutorAdapter + arguments: + $concurrency: 100 +``` + +## Consequences + +### Positive + +✅ **Architecture Compliance** +- PHPArkitect now passes with 0 violations +- Clean Architecture principles respected +- Application layer only depends on Domain + +✅ **Testability** +- Easy to mock Ports in unit tests +- Created comprehensive test suite for `AsyncBenchmarkRunner` (5 tests, 20+ assertions) +- No need to mock external libraries + +✅ **Flexibility** +- Can swap Spatie\Async for another library (ReactPHP, Amp, etc.) without touching Application layer +- Can replace Symfony EventDispatcher if needed +- Domain remains framework-agnostic + +✅ **Maintainability** +- Clear separation of concerns +- Explicit dependencies through interfaces +- Better code documentation + +### Negative + +⚠️ **Slight Complexity Increase** +- Two additional interfaces to maintain +- Two adapter classes +- Service configuration in `services.yaml` + +⚠️ **Indirection** +- One extra layer between Application and Infrastructure +- Slightly more files to navigate + +### Neutral + +🔵 **No Performance Impact** +- Adapters are thin wrappers +- No runtime overhead + +## Implementation Details + +### Files Created + +1. `src/Domain/Benchmark/Port/EventDispatcherPort.php` +2. `src/Domain/Benchmark/Port/AsyncExecutorPort.php` +3. `src/Infrastructure/Async/SymfonyEventDispatcherAdapter.php` +4. `src/Infrastructure/Async/SpatieAsyncExecutorAdapter.php` +5. `tests/Unit/Application/UseCase/AsyncBenchmarkRunnerTest.php` + +### Files Modified + +1. `src/Application/UseCase/AsyncBenchmarkRunner.php` - Now uses Ports instead of concrete implementations +2. `config/services.yaml` - Added Port → Adapter bindings +3. `.github/workflows/quality.yml` - Added PHPArkitect check to CI + +### Test Coverage + +Created comprehensive test suite covering: +- BenchmarkStarted event dispatching +- Benchmark execution and result persistence +- Progress events for each iteration +- BenchmarkCompleted event after all iterations +- Async executor wait() call + +**Result:** 5 tests, 20+ assertions, 100% success + +## Alternatives Considered + +### 1. Keep Direct Dependencies +**Rejected:** Violates Clean Architecture, harder to test, tightly coupled + +### 2. Move AsyncBenchmarkRunner to Infrastructure +**Rejected:** Use cases belong in Application layer by definition + +### 3. Create a Single "ExecutionPort" combining both concerns +**Rejected:** Single Responsibility Principle - event dispatching and async execution are separate concerns + +## References + +- [ADR 001: Hexagonal Architecture](001-hexagonal-architecture.md) +- [docs/architecture/03-ports-adapters.md](../architecture/03-ports-adapters.md) +- [PHPArkitect Configuration](../../phparkitect.php) +- [Clean Architecture by Robert C. Martin](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) + +## Validation + +✅ PHPStan Level Max: PASS (0 errors) +✅ PHPArkitect: PASS (0 violations) +✅ PHPUnit: PASS (46 tests, 181 assertions) +✅ PHP-CS-Fixer: PASS diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..cedaffa --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,24 @@ +# Architecture Decision Records (ADR) + +This directory contains records of architectural decisions made for this project. + +## What is an ADR? + +An Architecture Decision Record (ADR) is a document that captures an important architectural decision made along with its context and consequences. + +## Format + +Each ADR follows this structure: +- **Title**: Short noun phrase +- **Status**: Proposed, Accepted, Deprecated, Superseded +- **Context**: What forces are at play +- **Decision**: What we decided +- **Consequences**: What becomes easier or harder + +## Index + +- [ADR-001](001-hexagonal-architecture.md) - Hexagonal Architecture (Ports & Adapters) +- [ADR-002](002-symfony-validator-for-fixtures.md) - Use Symfony Validator for YAML Fixtures +- [ADR-003](003-mercure-for-realtime.md) - Use Mercure for Real-Time Updates +- [ADR-004](004-docker-for-benchmark-isolation.md) - Use Docker for Benchmark Execution Isolation +- [ADR-005](005-phpstan-level-max.md) - Enforce PHPStan Level Max diff --git a/docs/architecture/02-layers.md b/docs/architecture/02-layers.md index 9cfcd33..e195966 100644 --- a/docs/architecture/02-layers.md +++ b/docs/architecture/02-layers.md @@ -9,9 +9,10 @@ The **Domain Layer** is the heart of the application. It contains pure business ``` src/Domain/ ├── Benchmark/ -│ ├── Contract/ # Base abstractions -│ │ ├── AbstractBenchmark.php -│ │ └── Benchmark.php # Interface +│ ├── Event/ # Domain Events +│ │ ├── BenchmarkStarted.php +│ │ ├── BenchmarkProgress.php +│ │ └── BenchmarkCompleted.php │ │ │ ├── Exception/ # Domain-specific exceptions │ │ ├── BenchmarkNotFound.php @@ -29,16 +30,20 @@ src/Domain/ │ │ ├── ResultPersisterPort.php │ │ └── ScriptExecutorPort.php │ │ -│ ├── Service/ # Domain Services -│ │ └── SingleBenchmarkExecutor.php +│ └── Service/ # Domain Services +│ └── SingleBenchmarkExecutor.php +│ +├── Dashboard/ +│ ├── Model/ # Dashboard Value Objects +│ │ ├── BenchmarkMetrics.php +│ │ ├── BenchmarkStatistics.php +│ │ └── PercentileMetrics.php +│ │ +│ ├── Port/ # Dashboard Ports +│ │ └── DashboardRepositoryPort.php │ │ -│ └── Test/ # Benchmark implementations -│ ├── Loop.php -│ ├── ArrayMap/ -│ │ ├── MapWithArrayMap.php -│ │ └── MapWithForeach.php -│ ├── StringConcatenation/ -│ └── ... (40+ benchmarks) +│ └── Service/ # Dashboard Services +│ └── StatisticsCalculator.php │ └── PhpVersion/ ├── Attribute/ # PHP version targeting @@ -47,6 +52,9 @@ src/Domain/ │ └── ... └── Enum/ └── PhpVersion.php + +Note: Benchmarks are now stored in database via YAML fixtures (fixtures/benchmarks/*.yaml) + and loaded by Infrastructure layer (see Persistence section) ``` ### Namespace Pattern @@ -170,7 +178,8 @@ The **Infrastructure Layer** contains all technical implementations: frameworks, ``` src/Infrastructure/ ├── Cli/ # Command Line Interface -│ └── BenchmarkCommand.php +│ └── Command/ +│ └── BenchmarkCommand.php │ ├── Execution/ # Benchmark execution │ ├── CodeExtraction/ @@ -180,19 +189,31 @@ src/Infrastructure/ │ └── ScriptBuilding/ │ └── InstrumentedScriptBuilder.php │ +├── Mercure/ # Real-time events +│ └── EventSubscriber/ +│ └── BenchmarkProgressSubscriber.php +│ ├── Persistence/ # Data persistence │ ├── Doctrine/ │ │ ├── Entity/ -│ │ │ └── Pulse.php +│ │ │ ├── Benchmark.php # Benchmark definitions (from YAML) +│ │ │ └── Pulse.php # Execution results +│ │ ├── Fixtures/ +│ │ │ └── YamlBenchmarkFixtures.php # Loads benchmarks from YAML │ │ ├── Repository/ +│ │ │ ├── DoctrineDashboardRepository.php │ │ │ └── PulseRepository.php │ │ └── DoctrinePulseResultPersister.php │ └── InMemory/ │ └── InMemoryBenchmarkRepository.php │ └── Web/ # HTTP layer - └── Controller/ - └── DashboardController.php + ├── Component/ + │ └── BenchmarkProgressComponent.php # Live Component + ├── Controller/ + │ └── DashboardController.php + └── Presentation/ + └── ChartBuilder.php ``` ### Namespace Pattern diff --git a/docs/guides/atomic-commits.md b/docs/guides/atomic-commits.md new file mode 100644 index 0000000..60bd997 --- /dev/null +++ b/docs/guides/atomic-commits.md @@ -0,0 +1,442 @@ +# Atomic Commits Guide + +**Status**: Best Practice +**Audience**: Developers, AI Coding Agents + +## What is an Atomic Commit? + +An **atomic commit** is a commit that: +- ✅ Contains **a single logical change** +- ✅ Is **complete** and **functional** by itself +- ✅ Can be **reverted** without breaking the project +- ✅ Has a **descriptive message** explaining the "why" + +## Why It Matters + +### 1. Readable Git History +```bash +# ❌ Bad - Catch-all commit +43ed2c3 fix: various changes + +# ✅ Good - Atomic commits +43ed2c3 fix: use benchmark slug instead of class name for database benchmarks +0608e35 config: use DatabaseCodeExtractor for benchmark execution +09f85f7 feat: add DatabaseCodeExtractor for YAML benchmarks +``` + +### 2. Easier Debugging +```bash +# Find when a bug was introduced +git bisect start +git bisect bad HEAD +git bisect good v1.0.0 + +# With atomic commits: finds the exact responsible commit +# Without atomic commits: finds a large commit with 10 changes +``` + +### 3. Simplified Code Review +- Each commit can be reviewed independently +- Logical changes grouped together +- Easier to understand intent + +### 4. Reversibility +```bash +# Revert a specific feature +git revert 43ed2c3 # Only reverts the slug fix + +# With large commits: impossible to revert just one part +``` + +## Rules for Atomic Commits + +### Rule 1: One Responsibility per Commit + +**❌ Bad** - Multiple unrelated changes: +```bash +git commit -am "Add feature X, fix bug Y, update docs Z" +``` + +**✅ Good** - Separate commits: +```bash +git commit -m "feat: add feature X" +git commit -m "fix: resolve bug Y in component Z" +git commit -m "docs: update API documentation" +``` + +### Rule 2: Complete and Functional Commit + +**❌ Bad** - Broken code: +```bash +# Commit 1: Add new method (but not used anywhere) +# Commit 2: Call the method (but method signature wrong) +# Commit 3: Fix method signature +# → Commits 1 and 2 break the build +``` + +**✅ Good** - Each commit compiles: +```bash +# Commit 1: Add and integrate new method (complete feature) +# Commit 2: Refactor method for better performance +# → Each commit is functional +``` + +### Rule 3: Descriptive Message + +**❌ Bad** - Vague message: +```bash +git commit -m "fix stuff" +git commit -m "update" +git commit -m "changes" +``` + +**✅ Good** - Explanatory message: +```bash +git commit -m "fix: use benchmark slug instead of class name for database benchmarks + +Problem: DatabaseBenchmark::class returns same value for all YAML benchmarks +Solution: Use slug (e.g., 'iterate-with-for') instead of class name +Impact: Results now correctly identifiable in database" +``` + +## Practical Example: Fix YAML Benchmarks + +### Context +After migration to YAML fixtures, benchmarks no longer execute. Multiple issues to resolve. + +### ❌ Monolithic Approach (1 large commit) +```bash +git add . +git commit -m "fix: YAML benchmarks" + +# Contains: +# - New DatabaseCodeExtractor.php +# - Modified services.yaml +# - Modified DoctrinePulseResultPersister.php +# - Documentation FIX_YAML_BENCHMARKS.md +``` + +**Problems**: +- ❌ Impossible to understand each change +- ❌ If one change is bad, must revert everything +- ❌ Difficult to do code review +- ❌ Uninformative git history + +### ✅ Atomic Approach (4 separate commits) + +```bash +# Commit 1: Create the solution +git add src/Infrastructure/Execution/CodeExtraction/DatabaseCodeExtractor.php +git commit -m "feat: add DatabaseCodeExtractor for YAML benchmarks" + +# Commit 2: Configure usage +git add config/services.yaml +git commit -m "config: use DatabaseCodeExtractor for benchmark execution" + +# Commit 3: Fix persister bug +git add src/Infrastructure/Persistence/Doctrine/DoctrinePulseResultPersister.php +git commit -m "fix: use benchmark slug instead of class name for database benchmarks" + +# Commit 4: Document changes +git add docs/guides/fix-yaml-benchmarks.md +git commit -m "docs: document YAML benchmark execution fix" +``` + +**Benefits**: +- ✅ Each commit has a clear purpose +- ✅ Can revert a specific commit if needed +- ✅ Easy to understand evolution +- ✅ Code review commit by commit +- ✅ Git history tells a story + +## Good Commit Message Structure + +### Recommended Format (Conventional Commits) + +``` +: + + + + + + +``` + +### Commit Types + +| Type | Usage | Example | +|------|-------|---------| +| `feat` | New feature | `feat: add DatabaseCodeExtractor` | +| `fix` | Bug fix | `fix: use benchmark slug instead of class name` | +| `refactor` | Refactoring (no functional change) | `refactor: extract method for clarity` | +| `docs` | Documentation only | `docs: add atomic commits guide` | +| `test` | Add/modify tests | `test: add unit tests for CodeExtractor` | +| `config` | Configuration | `config: use DatabaseCodeExtractor` | +| `chore` | Maintenance tasks | `chore: update dependencies` | +| `perf` | Performance improvement | `perf: optimize database queries` | + +### Complete Example + +``` +fix: use benchmark slug instead of class name for database benchmarks + +DoctrinePulseResultPersister now uses benchmark slug for DatabaseBenchmark +instances instead of class name. + +Problem: +- $benchmark::class returns 'DatabaseBenchmark' for all YAML benchmarks +- Results were saved with wrong identifier +- Impossible to distinguish between benchmarks + +Solution: +- Check if benchmark is DatabaseBenchmark instance +- Use slug (e.g., 'iterate-with-for') for YAML benchmarks +- Use class name for legacy PHP class benchmarks + +Impact: +- Results now correctly identifiable in database +- Backward compatibility maintained for PHP class benchmarks + +Before: name='DatabaseBenchmark', bench_id='DatabaseBenchmark' ❌ +After: name='iterate-with-for', bench_id='iterate-with-for' ✅ +``` + +## Atomic Commits Workflow + +### 1. Plan Commits + +**Before coding**: +``` +Task: Fix YAML benchmark execution + +Planned commits: +1. Create DatabaseCodeExtractor +2. Configure services.yaml +3. Fix DoctrinePulseResultPersister +4. Add documentation +``` + +### 2. Code and Commit Progressively + +```bash +# ❌ Don't code everything then commit +# Code 4h → git add . → git commit + +# ✅ Commit as you go +# Code 30min → git add file1 → git commit +# Code 30min → git add file2 → git commit +# etc. +``` + +### 3. Use Selective git add + +```bash +# Add file by file +git add src/Infrastructure/Execution/CodeExtraction/DatabaseCodeExtractor.php +git commit -m "feat: add DatabaseCodeExtractor" + +# Or add by patch (-p) to select parts +git add -p config/services.yaml +git commit -m "config: update CodeExtractor" +``` + +### 4. Verify Before Committing + +```bash +# See what will be committed +git diff --staged + +# Verify code compiles +make quality +make phpstan + +# Then commit +git commit -m "..." +``` + +## Anti-Patterns to Avoid + +### ❌ Anti-Pattern 1: WIP Commits + +```bash +git commit -m "WIP" +git commit -m "WIP2" +git commit -m "final version" +git commit -m "final version for real" +``` + +**Solution**: Use `git commit --amend` or `git rebase -i` to clean up. + +### ❌ Anti-Pattern 2: Catch-All Commits + +```bash +git commit -am "update everything" +``` + +**Solution**: `git add` file by file with separate commits. + +### ❌ Anti-Pattern 3: Broken Commits + +```bash +# Commit 1: Add method (but incomplete) +# Commit 2: Fix compilation error +``` + +**Solution**: Wait for code to be complete before committing. + +### ❌ Anti-Pattern 4: Vague Messages + +```bash +git commit -m "fix" +git commit -m "changes" +git commit -m "update code" +``` + +**Solution**: Explain **what**, **why** and **how**. + +## For AI Coding Agents + +### Directives for Claude/GPT/Agents + +1. **Always** analyze changes before committing +2. **Group** modifications by logical responsibility +3. **Create** one commit per responsibility +4. **Write** descriptive messages with context +5. **Verify** each commit is functional +6. **Document** important decisions + +### Workflow Template + +```bash +# Step 1: Analyze changes +git status +git diff + +# Step 2: Identify logical groups +# Group 1: New DatabaseCodeExtractor file +# Group 2: services.yaml configuration +# Group 3: Persister fix + +# Step 3: Commit atomically +git add group_1_file +git commit -m "type: description group 1" + +git add group_2_file +git commit -m "type: description group 2" + +git add group_3_file +git commit -m "type: description group 3" + +# Step 4: Verify history +git log --oneline -5 +``` + +## Useful Tools + +### View History + +```bash +# Compact history +git log --oneline -20 + +# History with modified files +git log --stat -10 + +# Graphical history +git log --graph --oneline --all -20 + +# Search for a commit +git log --grep="DatabaseCodeExtractor" +``` + +### Modify History (Before Push) + +```bash +# Modify last commit +git commit --amend + +# Modify multiple commits +git rebase -i HEAD~3 + +# Squash multiple commits into one +git rebase -i HEAD~3 +# Then mark commits to squash with 's' +``` + +### Undo Changes + +```bash +# Undo last commit (keep changes) +git reset --soft HEAD~1 + +# Undo a specific commit (creates new commit) +git revert abc1234 + +# Remove file from staging +git restore --staged file.php +``` + +## Pre-Commit Checklist + +Before each commit, verify: + +- [ ] Code compiles without errors +- [ ] Tests pass +- [ ] Commit contains only one responsibility +- [ ] Message explains the "why" +- [ ] No unrelated files (debug, temp, etc.) +- [ ] Changes are complete and functional +- [ ] Code respects project standards + +## Real Examples from this Project + +### Good History (Atomic Commits) + +```bash +43ed2c3 fix: use benchmark slug instead of class name for database benchmarks +0608e35 config: use DatabaseCodeExtractor for benchmark execution +09f85f7 feat: add DatabaseCodeExtractor for YAML benchmarks +a51de34 docs: refactor and harmonize documentation (fixtures + naming) +c0efef0 feat: add YAML benchmark fixtures (100+ benchmarks) +``` + +**Why it's good**: +- Each commit has clear responsibility +- Descriptive messages +- Logical order (implementation → configuration → documentation) +- Easy to understand evolution + +### Bad History (Before Refactoring) + +```bash +71a8530 chore: clean Dashboard dependencies +24e3ec6 chore: clean Dashboard dependencies +18a6e6a chore: clean Dashboard dependencies +e2db171 chore: clean Dashboard dependencies +... +``` + +**Why it's bad**: +- 22 commits with same message +- Impossible to understand what was done +- Impossible to revert a specific part +- Useless history + +## Conclusion + +Atomic commits are an **essential practice** for: +- 📖 **Readable** Git history +- 🐛 Easier **debugging** +- 🔍 Efficient **code reviews** +- ↩️ Precise **reversibility** +- 🤝 Better **collaboration** + +**Golden Rule**: Each commit should tell **one simple and complete story**. + +--- + +**See also**: +- [Conventional Commits](https://www.conventionalcommits.org/) +- [Git Best Practices](https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project) +- [CLAUDE.md](../../CLAUDE.md) - Project standards diff --git a/docs/guides/creating-benchmarks.md b/docs/guides/creating-benchmarks.md index 273607a..14c2dda 100644 --- a/docs/guides/creating-benchmarks.md +++ b/docs/guides/creating-benchmarks.md @@ -1,6 +1,38 @@ # Creating Benchmarks -## Quick Start +⚠️ **Important**: As of November 2025, benchmarks are now defined as **YAML fixtures** instead of PHP classes. + +📖 **See the complete guide**: [fixtures.md](fixtures.md) + +## Quick Start (New Method - YAML Fixtures) + +1. Create a YAML file in `fixtures/benchmarks/` +2. Define your benchmark metadata and code +3. Load with `make fixtures` + +**Example:** +```yaml +# fixtures/benchmarks/string-concat.yaml +slug: string-concat +name: 'String Concatenation' +category: 'String Operations' +phpVersions: [php84, php85] +code: | + $result = ''; + for ($i = 0; $i < 10000; $i++) { + $result .= 'test' . $i; + } +``` + +📖 **Full YAML fixtures guide**: [fixtures.md](fixtures.md) + +--- + +## Legacy Method (PHP Classes - Deprecated) + +This method is kept for reference but **new benchmarks should use YAML fixtures**. + +### Quick Start 1. Create a class in `src/Domain/Benchmark/Test/` 2. Extend `AbstractBenchmark` diff --git a/docs/guides/fixtures.md b/docs/guides/fixtures.md new file mode 100644 index 0000000..e6ba302 --- /dev/null +++ b/docs/guides/fixtures.md @@ -0,0 +1,431 @@ +# Benchmark Fixtures Guide + +**Status**: Active +**Last Updated**: 2025-11-03 + +## Overview + +Benchmarks are now defined as **YAML files** in `fixtures/benchmarks/` and loaded into the MariaDB database. This approach provides: + +- ✅ **Version control**: Benchmarks are tracked in Git +- ✅ **Easy editing**: No PHP code needed to create benchmarks +- ✅ **Database persistence**: Fast queries and relations +- ✅ **Separation of concerns**: Code definitions separate from execution logic +- ✅ **Bulk management**: Load 100+ benchmarks at once + +## Architecture + +``` +fixtures/benchmarks/*.yaml → YamlBenchmarkFixtures → Benchmark Entity → Database + (Source) (Doctrine Loader) (Doctrine ORM) (MariaDB) +``` + +### Components + +| Component | Location | Purpose | +|-----------|----------|---------| +| **YAML Files** | `fixtures/benchmarks/*.yaml` | Source of truth for benchmark definitions | +| **Fixture Loader** | `src/Infrastructure/Persistence/Doctrine/Fixtures/YamlBenchmarkFixtures.php` | Parses YAML and creates entities | +| **Benchmark Entity** | `src/Infrastructure/Persistence/Doctrine/Entity/Benchmark.php` | Doctrine ORM entity | +| **Database Table** | `benchmarks` | MariaDB table (migration `Version20251103175019`) | + +## YAML File Format + +### Required Fields + +```yaml +slug: unique-benchmark-name # Unique identifier (used in URLs) +name: 'Human Readable Name' # Display name +category: 'Category Name' # Group benchmarks (e.g., "Array Operations") +code: | # PHP code to execute (multiline) + // Your benchmark code here + $result = []; + for ($i = 0; $i < 1000; $i++) { + $result[] = $i; + } +phpVersions: # Array of PHP versions to test + - php56 + - php84 + - php85 +``` + +### Optional Fields + +```yaml +description: 'Detailed explanation' # Optional description (empty string if omitted) +icon: 🚀 # Optional emoji icon (null if omitted) +tags: # Optional array of tags (empty array if omitted) + - array + - performance + - loop +``` + +## Complete Example + +**File**: `fixtures/benchmarks/array-fill-benchmark.yaml` + +```yaml +slug: array-fill +name: 'Array Fill' +category: 'Array Operations' +description: 'Compare performance of array_fill() vs manual loop for array initialization' +icon: 📦 +tags: + - array + - fill + - initialization +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + // Benchmark: array_fill() performance + for ($i = 0; $i < 10000; $i++) { + $array = array_fill(0, 100, 'value'); + } +``` + +## Available PHP Versions + +Valid values for `phpVersions` array: + +```yaml +phpVersions: + - php56 # PHP 5.6 + - php70 # PHP 7.0 + - php71 # PHP 7.1 + - php72 # PHP 7.2 + - php73 # PHP 7.3 + - php74 # PHP 7.4 + - php80 # PHP 8.0 + - php81 # PHP 8.1 + - php82 # PHP 8.2 + - php83 # PHP 8.3 + - php84 # PHP 8.4 + - php85 # PHP 8.5 (alpha) +``` + +**Note**: These values must match the `PhpVersion` enum in `src/Domain/PhpVersion/Enum/PhpVersion.php`. + +## Loading Fixtures + +### Commands + +```bash +# Load fixtures (append to existing) +make fixtures +# or +docker-compose run --rm main php bin/console doctrine:fixtures:load --no-interaction + +# Reset database and load fixtures (recommended) +make db.refresh +``` + +### What Happens + +1. **Scan**: `YamlBenchmarkFixtures` scans `fixtures/benchmarks/*.yaml` +2. **Parse**: Each YAML file is parsed using Symfony YAML component +3. **Validate**: Required fields are validated +4. **Create**: Doctrine `Benchmark` entity is created +5. **Persist**: Entity is persisted to `benchmarks` table +6. **Errors**: Invalid files are logged but don't stop the process + +### Error Handling + +If a YAML file is invalid: +- ❌ Error is logged: `Failed to load benchmark fixture {filename}: {error}` +- ✅ Other files continue loading +- ✅ Process completes successfully + +Check logs: `docker-compose logs main` + +## Creating New Benchmarks + +### Step 1: Create YAML File + +```bash +# Create file in fixtures/benchmarks/ +touch fixtures/benchmarks/my-benchmark.yaml +``` + +### Step 2: Define Benchmark + +```yaml +slug: my-benchmark +name: 'My Custom Benchmark' +category: 'Custom Tests' +description: 'What this benchmark measures' +icon: 🚀 +tags: + - custom +phpVersions: + - php84 + - php85 +code: | + // Your benchmark code + $result = 0; + for ($i = 0; $i < 1000; $i++) { + $result += $i; + } +``` + +### Step 3: Load Fixtures + +```bash +make fixtures +``` + +### Step 4: Verify + +```bash +# Check database +docker-compose exec database mariadb -u root -p -e "SELECT slug, name FROM benchmarks WHERE slug='my-benchmark';" + +# Or visit dashboard +# http://localhost/dashboard +``` + +## Validation Rules + +### Slug Rules + +- ✅ Must be unique across all benchmarks +- ✅ Lowercase with hyphens (e.g., `array-fill-benchmark`) +- ✅ No spaces, no special characters +- ✅ Used in URLs and identifiers + +### Code Rules + +- ✅ Must not be empty +- ✅ Can use any valid PHP code +- ✅ Executed in isolated Docker containers +- ✅ No access to application code/database + +### PHP Version Rules + +- ✅ Must be non-empty array +- ✅ Must use valid enum values (`php56`, `php84`, etc.) +- ❌ Invalid values will cause errors + +## Fixture Loader Implementation + +```php +// src/Infrastructure/Persistence/Doctrine/Fixtures/YamlBenchmarkFixtures.php + +class YamlBenchmarkFixtures extends Fixture +{ + public function load(ObjectManager $manager): void + { + // 1. Find all YAML files + $finder = new Finder(); + $finder->files() + ->in($this->projectDir . '/fixtures/benchmarks') + ->name('*.yaml') + ->sortByName(); + + // 2. Parse each file + foreach ($finder as $file) { + $data = Yaml::parseFile($file->getRealPath()); + + // 3. Validate required fields + $this->validateRequiredFields($data, $file->getFilename()); + + // 4. Create entity + $benchmark = new Benchmark( + slug: $data['slug'], + name: $data['name'], + category: $data['category'], + description: $data['description'] ?? '', + code: trim($data['code']), + phpVersions: $data['phpVersions'], + tags: $data['tags'] ?? [], + icon: $data['icon'] ?? null + ); + + // 5. Persist + $manager->persist($benchmark); + } + + $manager->flush(); + } +} +``` + +## Database Schema + +```sql +CREATE TABLE benchmarks ( + id INT AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(255) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + category VARCHAR(100) NOT NULL, + description TEXT NOT NULL, + code TEXT NOT NULL, + tags JSON NOT NULL, + icon VARCHAR(10) DEFAULT NULL, + php_versions JSON NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_category (category), + INDEX idx_slug (slug) +); +``` + +## Best Practices + +### 1. Naming Conventions + +```yaml +# ✅ Good +slug: array-fill-with-value +name: 'Array Fill with Value' + +# ❌ Bad +slug: ArrayFillWithValue +name: 'array fill' +``` + +### 2. Categories + +Use consistent category names: + +```yaml +# Standard categories +category: 'Array Operations' +category: 'String Operations' +category: 'Loop Performance' +category: 'OOP Performance' +category: 'Function Calls' +``` + +### 3. PHP Version Selection + +```yaml +# ✅ Test modern PHP only +phpVersions: [php82, php83, php84, php85] + +# ✅ Test all PHP versions +phpVersions: [php56, php70, php71, php72, php73, php74, php80, php81, php82, php83, php84, php85] + +# ✅ Test specific feature (e.g., PHP 8.0+ named arguments) +phpVersions: [php80, php81, php82, php83, php84, php85] +``` + +### 4. Code Guidelines + +```yaml +code: | + // Use consistent iteration count + for ($i = 0; $i < 10000; $i++) { + // Benchmark code here + } +``` + +**Tips:** +- Use high iteration counts (10,000+) for accurate timing +- Avoid I/O operations (file, network, database) +- Keep code focused on one performance aspect +- Add comments explaining what's being tested + +### 5. Tags + +```yaml +tags: + - array # Data structure + - initialization # Operation type + - memory # Performance aspect +``` + +## Troubleshooting + +### Problem: Fixture not loading + +**Check:** +1. YAML syntax is valid: `php bin/console lint:yaml fixtures/benchmarks/my-benchmark.yaml` +2. All required fields are present +3. File extension is `.yaml` or `.yml` +4. File is in `fixtures/benchmarks/` directory + +**Solution:** +```bash +# Check logs +docker-compose logs main | grep "Failed to load" + +# Validate YAML manually +docker-compose run --rm main php bin/console lint:yaml fixtures/benchmarks/*.yaml +``` + +### Problem: Duplicate slug error + +**Error**: `Duplicate entry 'my-slug' for key 'UNIQ_41BC1C58989D9B62'` + +**Solution**: Each `slug` must be unique. Change the slug in your YAML file. + +### Problem: Invalid PHP version + +**Error**: `ValueError: "php90" is not a valid backing value for enum` + +**Solution**: Use valid PHP version values (`php56` to `php85`). + +### Problem: Empty code + +**Error**: `Code field cannot be empty in my-benchmark.yaml` + +**Solution**: Add actual PHP code in the `code:` field. + +## Migration from Old System + +### Before (PHP Classes) + +```php +// src/Benchmark/Pulse/MyBenchmark.php +final class MyBenchmark extends AbstractBenchmark +{ + #[All] + public function execute(): void + { + // Code here + } +} +``` + +### After (YAML Fixtures) + +```yaml +# fixtures/benchmarks/my-benchmark.yaml +slug: my-benchmark +name: 'My Benchmark' +category: 'Tests' +phpVersions: [php56, php70, php84, php85] # Replaces #[All] +code: | + // Code here (same as execute() body) +``` + +**Benefits:** +- No PHP class needed +- Easier to edit +- Version controlled +- Can be loaded/reloaded without code changes + +## Related Documentation + +- [Creating Benchmarks Guide](creating-benchmarks.md) - Full guide with examples +- [Architecture Overview](../architecture/01-overview.md) - Clean Architecture principles +- [CLAUDE.md](../../CLAUDE.md) - Developer reference with all commands + +## Next Steps + +1. Browse existing fixtures: `fixtures/benchmarks/*.yaml` +2. Create your own benchmark YAML file +3. Load with `make fixtures` +4. Run with `make run test=YourSlug` +5. View results at `http://localhost/dashboard` diff --git a/docs/guides/mutation-testing.md b/docs/guides/mutation-testing.md new file mode 100644 index 0000000..f0c4ed1 --- /dev/null +++ b/docs/guides/mutation-testing.md @@ -0,0 +1,318 @@ +# Mutation Testing with Infection + +## Overview + +Infection is a PHP mutation testing framework that tests the quality of your tests by introducing small changes (mutations) to your code and checking if your tests catch them. + +## Why Mutation Testing? + +- **Tests your tests**: Code coverage shows which lines are executed, but not if assertions are effective +- **Finds weak tests**: Reveals tests that pass even when code logic changes +- **Improves test quality**: Encourages writing better assertions +- **Higher confidence**: Ensures tests actually protect against regressions + +## Installation + +Infection is already installed as a dev dependency: + +```bash +composer require --dev infection/infection +``` + +## Configuration + +Configuration is in `infection.json5`: + +```json5 +{ + "$schema": "vendor/infection/infection/resources/schema.json", + + "source": { + "directories": ["src/Domain", "src/Application"], + "excludes": ["src/Domain/Benchmark/Test"] + }, + + "minMsi": 80, // Minimum Mutation Score Indicator + "minCoveredMsi": 85, // Minimum Covered Code MSI + + "mutators": { + "@default": true + } +} +``` + +## Running Infection + +### Prerequisites + +Infection requires a code coverage driver. Since the Docker container doesn't have Xdebug installed by default, there are two options: + +#### Option 1: Using phpdbg (Recommended - Already Available) + +```bash +# Generate coverage + run mutations +make infection-report +``` + +#### Option 2: Install PCOV Extension + +Add to your `Dockerfile`: + +```dockerfile +RUN pecl install pcov && docker-php-ext-enable pcov +``` + +Then rebuild: + +```bash +docker-compose build main +make infection +``` + +### Commands + +```bash +# Run mutation testing with thresholds (MSI >= 80%, Covered MSI >= 85%) +make infection + +# Run mutation testing without threshold (for reports only) +make infection-report + +# Generate test coverage first +make test-coverage +``` + +### Manual Execution + +```bash +# With phpdbg +docker-compose run --rm main phpdbg -qrr vendor/bin/infection --threads=4 + +# With pre-generated coverage +docker-compose run --rm main phpdbg -qrr vendor/bin/phpunit --coverage-xml=var/coverage/coverage-xml --log-junit=var/coverage/junit.xml +docker-compose run --rm main vendor/bin/infection --coverage=var/coverage --threads=4 + +# Filter specific files +docker-compose run --rm main phpdbg -qrr vendor/bin/infection --filter=AsyncBenchmarkRunner.php + +# Show mutations +docker-compose run --rm main phpdbg -qrr vendor/bin/infection --show-mutations +``` + +## Understanding Results + +### Mutation Score Indicator (MSI) + +``` +MSI = (Killed mutations / Total mutations) × 100 +``` + +- **Killed**: Test failed when mutation was applied ✅ (Good!) +- **Escaped**: Test passed despite mutation ❌ (Bad - weak test) +- **Uncovered**: Code not covered by tests ⚠️ +- **Timeout**: Test took too long 🐌 +- **Error**: Mutation caused fatal error 💥 + +### Score Interpretation + +| MSI | Quality | Action | +|-----|---------|--------| +| 85%+ | Excellent | Maintain quality | +| 70-84% | Good | Improve weak tests | +| 50-69% | Fair | Add assertions | +| <50% | Poor | Review test strategy | + +### Example Output + +``` +46 mutations were generated: + 23 mutants were killed + 3 mutants were not covered by tests + 20 mutants were escaped + +Metrics: + Mutation Score Indicator (MSI): 53% + Mutation Code Coverage: 93% + Covered Code MSI: 57% +``` + +## Improving Mutation Score + +### 1. Escaped Mutations + +**Problem**: Tests pass even when logic changes + +```php +// Original code +public function isValid(): bool +{ + return $this->value > 0; // Mutation: > becomes >= +} + +// Weak test (passes with mutation) +public function testIsValid(): void +{ + $obj = new MyClass(5); + self::assertTrue($obj->isValid()); // Still passes with >= +} + +// Strong test (catches mutation) +public function testIsValidReturnsFalseForZero(): void +{ + $obj = new MyClass(0); + self::assertFalse($obj->isValid()); // Fails with >= +} +``` + +### 2. Uncovered Code + +Add tests for untested lines: + +```bash +# Check coverage +docker-compose run --rm main vendor/bin/phpunit --coverage-text + +# Target: > 80% line coverage +``` + +### 3. Common Mutations + +| Mutator | Example | Fix | +|---------|---------|-----| +| `>` → `>=` | Boundary conditions | Test edge cases (0, -1) | +| `&&` → `\|\|` | Logic operators | Test both true/false | +| `+` → `-` | Math operators | Test calculations | +| `true` → `false` | Boolean values | Test boolean logic | +| `===` → `!==` | Comparisons | Test equality | + +## Best Practices + +### 1. Run Regularly + +```bash +# In CI/CD (see .github/workflows/quality.yml) +- name: Run Infection + run: make infection-report +``` + +### 2. Focus on Critical Code + +```json5 +{ + "source": { + "directories": ["src/Domain", "src/Application"] // Core logic only + } +} +``` + +### 3. Set Realistic Thresholds + +Start low, increase gradually: + +```bash +# Initial run +--min-msi=60 --min-covered-msi=70 + +# After improvements +--min-msi=80 --min-covered-msi=85 +``` + +### 4. Review Escaped Mutations + +```bash +# Show specific mutations +make infection-report | grep "Escaped" + +# Analyze HTML report +open var/infection/html/index.html +``` + +## Troubleshooting + +### Error: No coverage driver available + +**Solution 1**: Use phpdbg (already available) +```bash +make infection-report +``` + +**Solution 2**: Install PCOV +```dockerfile +RUN pecl install pcov && docker-php-ext-enable pcov +``` + +### Error: Tests failing randomly + +Infection runs tests in random order. Fix test dependencies: + +```xml + + +``` + +### Timeout errors + +Increase timeout in `infection.json5`: + +```json5 +{ + "timeout": 20 // seconds +} +``` + +### Too slow + +```bash +# Use more threads +--threads=8 + +# Skip initial tests (if coverage already generated) +--skip-initial-tests + +# Filter specific files +--filter=src/Application/ +``` + +## CI/CD Integration + +Add to `.github/workflows/quality.yml`: + +```yaml +- name: Run Mutation Testing + run: | + docker-compose run --rm main phpdbg -qrr vendor/bin/infection \ + --threads=4 \ + --min-msi=80 \ + --min-covered-msi=85 \ + --logger-github +``` + +## Resources + +- [Infection Documentation](https://infection.github.io/) +- [Mutation Testing Introduction](https://infection.github.io/guide/mutation-testing.html) +- [Mutators Reference](https://infection.github.io/guide/mutators.html) +- [PHPUnit with Infection](https://infection.github.io/guide/phpunit.html) + +## Example: AsyncBenchmarkRunner + +Our `AsyncBenchmarkRunner` test suite has high mutation coverage: + +```php +// Tests check: +- Event dispatching (BenchmarkStarted, Progress, Completed) +- Result persistence +- Async executor wait() call +- Multiple iterations handling + +// Result: Strong mutation score because: +✅ Each assertion has a purpose +✅ Edge cases are tested (0, 1, multiple iterations) +✅ Mock expectations verify behavior +✅ No redundant tests +``` + +**Target**: Maintain MSI > 80% for Domain and Application layers. diff --git a/docs/infrastructure/docker.md b/docs/infrastructure/docker.md new file mode 100644 index 0000000..c94ac72 --- /dev/null +++ b/docs/infrastructure/docker.md @@ -0,0 +1,568 @@ +# Docker Infrastructure + +This document describes the Docker architecture used in the PHP Benchmark project. + +## Table of Contents + +1. [Overview](#overview) +2. [Architecture Diagram](#architecture-diagram) +3. [Services](#services) +4. [Resource Constraints](#resource-constraints) +5. [Networking](#networking) +6. [Volumes](#volumes) +7. [Execution Flow](#execution-flow) +8. [Dockerfiles](#dockerfiles) +9. [Real-Time Updates](#real-time-updates) + +## Overview + +The PHP Benchmark project uses a multi-container Docker architecture to: + +- **Isolate PHP versions**: Each PHP version runs in its own container for accurate benchmarking +- **Ensure fair testing**: Resource limits (CPU, memory) prevent version-specific bias +- **Manage dependencies**: The main container orchestrates benchmark execution across all PHP containers +- **Persist results**: MariaDB stores benchmark results for the web dashboard +- **Enable real-time updates**: Mercure broadcasts benchmark progress to the web interface + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Host Machine │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ Docker Network │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌─────────────┐ ┌────────────┐ │ │ +│ │ │ main │────────▶│ mariadb │ │ mercure │ │ │ +│ │ │ (PHP 8.4) │ │ (MariaDB │ │ (SSE Hub) │ │ │ +│ │ │ │ │ 10.11) │ │ Port 3000 │ │ │ +│ │ │ - Web server │ │ │ │ │ │ │ +│ │ │ - CLI │ └─────────────┘ └──────▲─────┘ │ │ +│ │ │ - Orchestrator│ │ │ │ │ +│ │ └──────┬───────┘ │ │ │ │ +│ │ │ │ Publishes │ │ +│ │ │ docker-compose exec │ Updates │ │ +│ │ ▼ ▼ │ │ │ +│ │ ┌─────────────────────────────────────────────┐ │ │ │ +│ │ │ Benchmark Execution Containers │──┘ │ │ +│ │ │ │ │ │ +│ │ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌───────┐│ │ │ +│ │ │ │ php56 │ │ php70 │ │ php71 │ │ ... ││ │ │ +│ │ │ └────────┘ └────────┘ └────────┘ └───────┘│ │ │ +│ │ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌───────┐│ │ │ +│ │ │ │ php80 │ │ php81 │ │ php82 │ │ php83 ││ │ │ +│ │ │ └────────┘ └────────┘ └────────┘ └───────┘│ │ │ +│ │ │ ┌────────┐ ┌────────┐ │ │ │ +│ │ │ │ php84 │ │ php85 │ │ │ │ +│ │ │ └────────┘ └────────┘ │ │ │ +│ │ │ │ │ │ +│ │ │ All containers: 512MB RAM, 1 CPU core │ │ │ +│ │ └─────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ └────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + ▲ + │ SSE (Server-Sent Events) + │ + Browser Clients +``` + +## Services + +### 1. Main Service (`main`) + +**Purpose**: Primary application container running Symfony 7.3 on PHP 8.4 + +**Key responsibilities**: +- Serve the web dashboard (port 8000) +- Execute CLI commands (`bin/console`) +- Orchestrate benchmark execution across PHP containers +- Manage database migrations and ORM operations + +**Image**: Custom `Dockerfile.main` based on `php:8.4-cli` + +**Exposed ports**: `8000:8000` (web server) + +**Environment variables**: +- `DOCKER_HOST=unix:///var/run/docker.sock` - Access to Docker daemon +- `DATABASE_URL=mysql://root:password@mariadb:3306/php_benchmark` + +**Volumes**: +- `./:/srv/php_benchmark` - Project source code (bind mount) +- `/var/run/docker.sock:/var/run/docker.sock` - Docker socket for orchestration + +**Command**: `php -S 0.0.0.0:8000 -t public` (built-in PHP web server) + +--- + +### 2. MariaDB Service (`mariadb`) + +**Purpose**: Relational database for storing benchmark results + +**Image**: Official `mariadb:10.11` + +**Exposed ports**: `3306:3306` (MySQL protocol) + +**Environment variables**: +- `MYSQL_ROOT_PASSWORD=password` +- `MYSQL_DATABASE=php_benchmark` +- `MYSQL_USER=php_user` +- `MYSQL_PASSWORD=php_password` + +**Volumes**: +- `mariadb_data:/var/lib/mysql` - Named volume for data persistence + +**Character set**: UTF-8 (`utf8mb4_unicode_ci`) + +**Restart policy**: `unless-stopped` + +--- + +### 3. PHP Benchmark Containers (`php56` → `php85`) + +**Purpose**: Isolated environments for running benchmarks on different PHP versions + +**Supported versions**: +- **PHP 5.6** → **PHP 8.4**: Official Docker Hub images (`php:X.Y-cli`) +- **PHP 8.5**: Custom build from source (alpha version) + +**Common configuration**: +- **Working directory**: `/srv/php_benchmark` +- **Volume**: `./:/srv/php_benchmark` (shared codebase) +- **Memory limit**: 512 MB +- **CPU limit**: 1 core +- **Command**: `tail -f /dev/null` (keeps container running in idle state) + +**Why idle state?** + +The containers run `tail -f /dev/null` to stay alive without consuming resources. The main container executes benchmarks on-demand using: + +```bash +docker-compose exec -T php .php +``` + +This approach: +- ✅ Avoids overhead of starting/stopping containers repeatedly +- ✅ Provides instant benchmark execution +- ✅ Simplifies orchestration logic + +--- + +## Resource Constraints + +All PHP benchmark containers have identical resource limits to ensure fair performance comparison: + +| Resource | Limit | Reason | +|----------|-------|--------| +| **Memory** | 512 MB | Prevents memory-intensive benchmarks from skewing results | +| **CPU** | 1 core | Ensures consistent CPU availability across tests | + +**Example configuration** (from `docker-compose.yml`): + +```yaml +php84: + image: php:8.4-cli + mem_limit: 512m + cpus: 1 +``` + +**Why these limits?** + +- **Fairness**: Prevents newer PHP versions from benefiting from more resources +- **Reproducibility**: Consistent environment across benchmark runs +- **Isolation**: Ensures one benchmark doesn't starve others + +--- + +## Networking + +**Default network**: Docker Compose creates a bridge network where all services can communicate using service names as hostnames. + +**Service discovery**: +- `main` → `mariadb` (database connection) +- `main` → `php56`, `php70`, ..., `php85` (benchmark execution) + +**Port exposure**: +- `main:8000` → `localhost:8000` (web dashboard) +- `mariadb:3306` → `localhost:3306` (database access for external tools) + +--- + +## Volumes + +### Named Volume: `mariadb_data` + +**Purpose**: Persist MariaDB data across container restarts + +**Location**: Managed by Docker (usually `/var/lib/docker/volumes/`) + +**Lifecycle**: Persists even if containers are removed + +**Backup recommendation**: + +```bash +# Export database +docker-compose exec mariadb mysqldump -u root -ppassword php_benchmark > backup.sql + +# Restore database +docker-compose exec -T mariadb mysql -u root -ppassword php_benchmark < backup.sql +``` + +### Bind Mount: `./:/srv/php_benchmark` + +**Purpose**: Share project code between host and containers + +**Benefits**: +- Hot-reload during development +- Benchmark scripts accessible to all PHP containers +- Temporary files shared across services + +**Mounted on**: +- `main` container +- All PHP benchmark containers (`php56` → `php85`) + +--- + +## Execution Flow + +### 1. Starting the Environment + +```bash +make up # or: docker-compose up -d +``` + +**What happens**: +1. Build custom images (`Dockerfile.main`, `Dockerfile.php85`) +2. Pull official PHP images (php:5.6-cli → php:8.4-cli) +3. Start MariaDB and wait for readiness +4. Start main container (depends on MariaDB) +5. Start all PHP benchmark containers in idle state + +### 2. Running a Benchmark + +**User executes**: + +```bash +docker-compose run --rm main php bin/console benchmark:run --test=Loop --php-version=php84 --iterations=10 +``` + +**Execution flow**: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 1. CLI Command (main container) │ +│ bin/console benchmark:run --test=Loop --php-version=php84 │ +└────────────┬───────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 2. Application Layer (Symfony) │ +│ BenchmarkCommand → ExecuteBenchmarkUseCase │ +└────────────┬───────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 3. Domain Layer │ +│ - BenchmarkOrchestrator: coordinates execution │ +│ - CodeExtractor: extracts benchmark code │ +│ - ScriptBuilder: generates executable PHP script │ +└────────────┬───────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 4. Infrastructure Layer - DockerScriptExecutor │ +│ - Creates temp file: /srv/php_benchmark/var/tmp/script.php │ +│ - Executes: docker-compose exec -T php84 php │ +│ - Parses JSON output │ +└────────────┬───────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 5. Target PHP Container (php84) │ +│ - Runs benchmark script │ +│ - Outputs JSON: {"avg": 0.0012, "p90": 0.0015, ...} │ +└────────────┬───────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 6. Persistence Layer │ +│ - DoctrinePulseResultPersister saves to MariaDB │ +│ - Stores: benchmark_id, php_version, metrics, timestamp │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Key implementation** (DockerScriptExecutor:48): + +```php +private function executeInDocker(string $phpVersion, string $scriptPath): string +{ + $command = sprintf( + 'docker-compose exec -T %s php %s 2>&1', + escapeshellarg($phpVersion), + escapeshellarg($scriptPath), + ); + + exec($command, $output, $exitCode); + // ... +} +``` + +### 3. Viewing Results + +**Web dashboard**: + +```bash +# Navigate to: http://localhost:8000 +``` + +**Direct database query**: + +```bash +docker-compose exec mariadb mysql -u root -ppassword php_benchmark -e "SELECT * FROM benchmark_results ORDER BY created_at DESC LIMIT 10;" +``` + +--- + +## Dockerfiles + +### Dockerfile.main + +**Purpose**: Main application container with development tools + +**Base image**: `php:8.4-cli` + +**Installed packages**: +- System libraries: `libzip-dev`, `default-mysql-client`, `git`, `curl` +- PHP extensions: `pdo_mysql`, `zip` (via PIE) +- Tools: `composer`, `docker-compose`, `PIE` (PHP Installer for Extensions) + +**Why docker-compose inside a container?** + +The main container needs to orchestrate other containers, so it runs `docker-compose exec` commands. The Docker socket bind mount (`/var/run/docker.sock`) enables this. + +**Build command**: + +```bash +docker-compose build main +``` + +**File**: `Dockerfile.main` + +--- + +### Dockerfile.php85 + +**Purpose**: Custom PHP 8.5 alpha build from source + +**Base image**: `debian:bullseye` + +**Why custom build?** + +PHP 8.5 is not yet officially released, so there's no Docker Hub image. This Dockerfile compiles PHP 8.5.0 Alpha 1 from source. + +**Build process**: +1. Install build dependencies (`build-essential`, `libxml2-dev`, etc.) +2. Download PHP 8.5.0 Alpha 1 tarball from php.net +3. Configure with: + - OpenSSL support + - Mbstring (multibyte strings) + - SOAP extension + - Intl (internationalization) +4. Compile with `make -j$(nproc)` (parallel build) +5. Install to `/usr/local/php8.5` +6. Add to `PATH` + +**Build command**: + +```bash +docker-compose build php85 +``` + +**File**: `Dockerfile.php85` + +**Note**: This image will need updating when PHP 8.5 reaches stable release. + +--- + +## Real-Time Updates + +### Mercure Service + +**Purpose**: Real-time Server-Sent Events (SSE) hub for broadcasting benchmark progress + +**Image**: Official `dunglas/mercure` + +**Exposed ports**: `3000:80` (HTTP, HTTPS disabled for development) + +**Environment variables**: +- `SERVER_NAME: ':80'` - HTTP mode (no HTTPS) +- `MERCURE_PUBLISHER_JWT_KEY` - Authentication for publishing updates +- `MERCURE_SUBSCRIBER_JWT_KEY` - Authentication for subscribers +- `MERCURE_EXTRA_DIRECTIVES` - CORS and anonymous access configuration + +**Volumes**: +- `mercure_data:/data` - Persistent data +- `mercure_config:/config` - Configuration files + +**How it works**: + +1. **Backend publishes**: When benchmarks run, Symfony publishes progress events to Mercure + ```php + $hub->publish(new Update('benchmark/progress', json_encode($data))); + ``` + +2. **Mercure broadcasts**: Mercure receives updates and broadcasts them to all subscribers + +3. **Frontend subscribes**: Browser connects via EventSource (SSE) + ```javascript + const eventSource = new EventSource('http://localhost:3000/.well-known/mercure?topic=benchmark/progress'); + ``` + +4. **Live updates**: Frontend receives real-time updates and updates UI automatically + +**Configuration**: See [mercure-realtime.md](mercure-realtime.md) for detailed setup + +**Topics**: +- `benchmark/progress` - All progress updates (start, progress, complete) +- `benchmark/results` - Final results only + +**Testing Mercure**: + +```bash +# Subscribe to updates (terminal 1) +curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" + +# Run benchmark (terminal 2) +make run test=Loop iterations=5 + +# Terminal 1 will show real-time SSE events +``` + +**Security note**: Current configuration uses `anonymous` mode for development. For production, implement proper JWT authentication. See [mercure-realtime.md#security](mercure-realtime.md#security). + +--- + +## Best Practices + +### Development + +1. **Hot-reload**: Code changes on host are immediately available in containers (bind mount) +2. **Dependency updates**: Run `composer install` inside main container: + ```bash + docker-compose exec main composer install + ``` +3. **Database migrations**: Execute inside main container: + ```bash + docker-compose exec main php bin/console doctrine:migrations:migrate + ``` + +### Production Considerations + +**Current setup is development-oriented**. For production, consider: + +- ❌ Don't expose MariaDB port `3306` publicly +- ❌ Don't use `root` password in environment variables +- ❌ Don't use `tail -f /dev/null` (use proper orchestration) +- ✅ Use Docker secrets for credentials +- ✅ Use environment-specific `docker-compose.override.yml` +- ✅ Enable Docker resource monitoring +- ✅ Implement health checks + +### Security + +**Docker socket access**: The main container has access to `/var/run/docker.sock`, which grants root-level control over Docker. This is acceptable for development but risky in production. + +**Mitigation**: +- Use Docker API with restricted permissions +- Consider rootless Docker +- Isolate benchmark containers with stricter security policies + +--- + +## Troubleshooting + +### Container won't start + +```bash +# Check logs +docker-compose logs + +# Example: Check MariaDB logs +docker-compose logs mariadb +``` + +### Database connection failed + +**Symptom**: `Connection refused` or `Unknown MySQL server host` + +**Solution**: + +```bash +# Ensure MariaDB is running +docker-compose ps mariadb + +# Test connection from main container +docker-compose exec main mysql -h mariadb -u root -ppassword php_benchmark +``` + +### Benchmark execution timeout + +**Symptom**: `Script execution failed with code 124` + +**Cause**: Benchmark took longer than allowed timeout + +**Solution**: Increase timeout in `DockerScriptExecutor` or reduce iterations + +### Out of memory errors + +**Symptom**: Benchmark container crashes with exit code 137 + +**Cause**: Exceeded 512MB memory limit + +**Solution**: Increase `mem_limit` in `docker-compose.yml` (note: may affect benchmark fairness) + +--- + +## Future Improvements + +### Proposed Enhancements + +1. **Health checks**: Add Docker health checks for all services + ```yaml + healthcheck: + test: ["CMD", "php", "-v"] + interval: 30s + timeout: 3s + retries: 3 + ``` + +2. **Multi-stage builds**: Reduce image size for PHP 8.5 + ```dockerfile + FROM debian:bullseye AS builder + # ... compile PHP ... + + FROM debian:bullseye-slim + COPY --from=builder /usr/local/php8.5 /usr/local/php8.5 + ``` + +3. **Container orchestration**: Consider Kubernetes for production scaling + +4. **Monitoring**: Add Prometheus exporters for container metrics + +5. **Caching**: Use Docker build cache optimization for faster rebuilds + +--- + +## References + +- [Docker Compose Documentation](https://docs.docker.com/compose/) +- [PHP Official Docker Images](https://hub.docker.com/_/php) +- [MariaDB Docker Image](https://hub.docker.com/_/mariadb) +- [Mercure Docker Image](https://hub.docker.com/r/dunglas/mercure) +- [DockerScriptExecutor Implementation](../../src/Infrastructure/Execution/Docker/DockerScriptExecutor.php) +- [Real-Time Updates Guide](mercure-realtime.md) + +--- + +**Last updated**: 2025-10-22 +**Maintained by**: Project contributors diff --git a/docs/infrastructure/mercure-implementation-summary.md b/docs/infrastructure/mercure-implementation-summary.md new file mode 100644 index 0000000..9cf0b34 --- /dev/null +++ b/docs/infrastructure/mercure-implementation-summary.md @@ -0,0 +1,622 @@ +# Mercure Real-Time Implementation - Summary + +**Date**: 2025-10-22 +**Status**: ✅ Completed and Tested + +## Overview + +This document summarizes the complete implementation of real-time benchmark progress updates using Mercure and Symfony UX Live Components. + +## What Was Implemented + +### 1. Infrastructure (Docker) + +**Added Mercure service** to `docker-compose.yml:149` + +```yaml +mercure: + image: dunglas/mercure + restart: unless-stopped + environment: + SERVER_NAME: ':80' + MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' + MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' + MERCURE_EXTRA_DIRECTIVES: | + cors_origins http://localhost:8000 http://127.0.0.1:8000 + anonymous + command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile + ports: + - "3000:80" + volumes: + - mercure_data:/data + - mercure_config:/config +``` + +**Status**: ✅ Running on http://localhost:3000 + +--- + +### 2. Backend Configuration + +#### Symfony Mercure Bundle + +**Installed**: `symfony/mercure-bundle` v0.3.9 + +**Configuration** (`config/packages/mercure.yaml`): +```yaml +mercure: + hubs: + default: + url: '%env(MERCURE_URL)%' + public_url: '%env(MERCURE_PUBLIC_URL)%' + jwt: + secret: '%env(MERCURE_JWT_SECRET)%' + publish: '*' +``` + +**Environment variables** (`.env:47`): +```env +MERCURE_URL=http://mercure/.well-known/mercure +MERCURE_PUBLIC_URL=http://localhost:3000/.well-known/mercure +MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" +``` + +--- + +### 3. Domain Events (Clean Architecture) + +Created 3 Domain events in `src/Domain/Benchmark/Event/`: + +#### BenchmarkStarted +```php +new BenchmarkStarted( + benchmarkId: 'Jblairy\PhpBenchmark\Domain\Benchmark\Test\Loop', + benchmarkName: 'Loop', + phpVersion: 'php84', + totalIterations: 100 +); +``` + +**Published**: When benchmark execution starts + +--- + +#### BenchmarkProgress +```php +new BenchmarkProgress( + benchmarkId: 'Jblairy\PhpBenchmark\Domain\Benchmark\Test\Loop', + benchmarkName: 'Loop', + phpVersion: 'php84', + currentIteration: 50, + totalIterations: 100 +); +``` + +**Published**: After each iteration completes +**Includes**: Progress percentage calculation + +--- + +#### BenchmarkCompleted +```php +new BenchmarkCompleted( + benchmarkId: 'Jblairy\PhpBenchmark\Domain\Benchmark\Test\Loop', + benchmarkName: 'Loop', + phpVersion: 'php84', + totalIterations: 100 +); +``` + +**Published**: When all iterations complete +**Note**: Statistics (avg, p90, p95, p99) are calculated separately and stored in database + +--- + +### 4. Event Publishing (Infrastructure) + +#### BenchmarkProgressSubscriber + +**Location**: `src/Infrastructure/Mercure/EventSubscriber/BenchmarkProgressSubscriber.php` + +**Responsibilities**: +- Listens to Domain events (started, progress, completed) +- Publishes JSON updates to Mercure hub +- Topic: `benchmark/progress` + +**Implementation**: +```php +final readonly class BenchmarkProgressSubscriber implements EventSubscriberInterface +{ + public function __construct(private HubInterface $hub) {} + + public function onBenchmarkProgress(BenchmarkProgress $event): void + { + $update = new Update( + 'benchmark/progress', + json_encode($event->toArray()) + ); + $this->hub->publish($update); + } +} +``` + +**Automatically registered**: Via Symfony autowiring + +--- + +### 5. Use Case Integration + +#### AsyncBenchmarkRunner + +**Location**: `src/Application/UseCase/AsyncBenchmarkRunner.php:28` + +**Changes**: +- Injected `EventDispatcherInterface` +- Dispatches `BenchmarkStarted` before execution +- Dispatches `BenchmarkProgress` after each iteration +- Dispatches `BenchmarkCompleted` after all iterations + +**Key code**: +```php +// Start event +$this->eventDispatcher->dispatch(new BenchmarkStarted(...)); + +// Progress events (in async callback) +->then(function ($result) { + $this->resultPersisterPort->persist(...); + ++$completedIterations; + + $this->eventDispatcher->dispatch(new BenchmarkProgress(...)); +}); + +// Completion event +$pool->wait(); +$this->eventDispatcher->dispatch(new BenchmarkCompleted(...)); +``` + +--- + +### 6. Frontend Components + +#### Live Component + +**Location**: `src/Infrastructure/Web/Component/BenchmarkProgressComponent.php` + +**Attributes**: `#[AsLiveComponent('BenchmarkProgress')]` + +**LiveProps**: +- `benchmarkId` (writable) +- `benchmarkName` (writable) +- `phpVersion` (writable) +- `currentIteration` (writable) +- `totalIterations` (writable) +- `status` (writable): `idle`, `started`, `running`, `completed` + +**Methods**: +- `getProgress()`: Calculates percentage (0-100) +- `isRunning()`: Check if benchmark is executing +- `isCompleted()`: Check if benchmark finished + +--- + +#### Twig Template + +**Location**: `templates/components/BenchmarkProgress.html.twig` + +**Features**: +- Progress bar with real-time percentage +- Current iteration / Total iterations display +- Status-based UI (idle, running, completed) +- Inline CSS styling + +**Usage**: +```twig +{{ component('BenchmarkProgress') }} +``` + +--- + +#### Stimulus Controller + +**Location**: `assets/controllers/mercure-progress_controller.js` + +**Responsibilities**: +- Connects to Mercure via EventSource (SSE) +- Subscribes to `benchmark/progress` topic +- Receives real-time updates +- Updates Live Component props +- Direct DOM manipulation for instant feedback + +**Values**: +- `url`: Mercure hub public URL +- `topic`: Topic to subscribe to + +**Event handling**: +```javascript +handleBenchmarkUpdate(data) { + switch (data.type) { + case 'benchmark.started': + // Update to show "running" state + case 'benchmark.progress': + // Update progress bar + case 'benchmark.completed': + // Show completion + } +} +``` + +--- + +### 7. Documentation + +#### Created + +1. **`docs/infrastructure/mercure-realtime.md`** + - Complete architecture guide + - Event flow diagrams + - Configuration instructions + - Troubleshooting section + - Security recommendations + +2. **`docs/infrastructure/mercure-implementation-summary.md`** (this file) + - Implementation overview + - Files created/modified + - Testing instructions + +#### Updated + +1. **`docs/infrastructure/docker.md`** + - Added Mercure service section + - Updated architecture diagram + - Testing examples + +2. **`docs/README.md`** + - Added link to Mercure documentation + +--- + +## Files Created + +### Backend +- `src/Domain/Benchmark/Event/BenchmarkStarted.php` +- `src/Domain/Benchmark/Event/BenchmarkProgress.php` +- `src/Domain/Benchmark/Event/BenchmarkCompleted.php` +- `src/Infrastructure/Mercure/EventSubscriber/BenchmarkProgressSubscriber.php` +- `src/Infrastructure/Web/Component/BenchmarkProgressComponent.php` +- `config/packages/mercure.yaml` (auto-generated by Flex) + +### Frontend +- `templates/components/BenchmarkProgress.html.twig` +- `assets/controllers/mercure-progress_controller.js` + +### Documentation +- `docs/infrastructure/mercure-realtime.md` +- `docs/infrastructure/mercure-implementation-summary.md` + +--- + +## Files Modified + +### Infrastructure +- `docker-compose.yml` - Added Mercure service +- `.env` - Added Mercure environment variables +- `Dockerfile.php85` - Fixed French comments to English + +### Application +- `src/Application/UseCase/AsyncBenchmarkRunner.php` + - Added EventDispatcher injection + - Added event dispatching logic + +### Documentation +- `docs/infrastructure/docker.md` - Added Mercure section +- `docs/README.md` - Updated index + +--- + +## Testing + +### ✅ Verified Working + +**1. Mercure Hub Health** +```bash +curl http://localhost:3000/.well-known/mercure +# Returns: 400 (expected - needs topic parameter) +``` + +**2. Real-Time Event Streaming** +```bash +# Terminal 1: Subscribe to events +curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" + +# Terminal 2: Run benchmark +make run test=Loop iterations=5 version=php84 + +# Terminal 1 output: +id: urn:uuid:bc8bc7a8-e0c5-40af-8547-dd21fc4a7306 +data: {"type":"benchmark.started","benchmarkId":"...","benchmarkName":"Loop","phpVersion":"php84","totalIterations":5,"timestamp":1761113607} + +id: urn:uuid:11031f73-897a-4d97-bf13-debe15b5ef20 +data: {"type":"benchmark.progress","benchmarkId":"...","benchmarkName":"Loop","phpVersion":"php84","currentIteration":1,"totalIterations":5,"progress":20,"timestamp":1761113608} + +[... 4 more progress events ...] + +id: urn:uuid:6aeaec5e-3c66-431d-8ea9-f417253091b6 +data: {"type":"benchmark.completed","benchmarkId":"...","benchmarkName":"Loop","phpVersion":"php84","totalIterations":5,"timestamp":1761113608} +``` + +**3. Benchmark Execution** +```bash +make run test=Loop iterations=3 version=php84 +# Output: [OK] Benchmark(s) completed successfully! +``` + +--- + +## Event Flow Example + +**Benchmark**: Loop on PHP 8.4, 5 iterations + +``` +1. BenchmarkStarted + ├─ Dispatched: AsyncBenchmarkRunner:35 + ├─ Handled: BenchmarkProgressSubscriber:onBenchmarkStarted + ├─ Published: Mercure topic "benchmark/progress" + └─ Broadcast: All connected browsers + +2. BenchmarkProgress (iteration 1/5) + ├─ Dispatched: AsyncBenchmarkRunner:54 + ├─ Handled: BenchmarkProgressSubscriber:onBenchmarkProgress + ├─ Published: {"currentIteration": 1, "totalIterations": 5, "progress": 20} + └─ Broadcast: Progress bar updates to 20% + +3. BenchmarkProgress (iteration 2/5) + └─ ... progress: 40% + +4. BenchmarkProgress (iteration 3/5) + └─ ... progress: 60% + +5. BenchmarkProgress (iteration 4/5) + └─ ... progress: 80% + +6. BenchmarkProgress (iteration 5/5) + └─ ... progress: 100% + +7. BenchmarkCompleted + ├─ Dispatched: AsyncBenchmarkRunner:68 + ├─ Handled: BenchmarkProgressSubscriber:onBenchmarkCompleted + ├─ Published: {"type": "benchmark.completed", "totalIterations": 5} + └─ Broadcast: UI shows "Completed! 5 iterations finished." +``` + +**Total time**: ~3 seconds for 5 iterations +**Events sent**: 7 (1 started + 5 progress + 1 completed) + +--- + +## Browser Integration + +### EventSource Connection + +**JavaScript** (automatic via Stimulus controller): +```javascript +const url = new URL('http://localhost:3000/.well-known/mercure'); +url.searchParams.append('topic', 'benchmark/progress'); + +const eventSource = new EventSource(url); +eventSource.onmessage = (event) => { + const data = JSON.parse(event.data); + // Update UI based on data.type +}; +``` + +### Live Component Usage + +**In any Twig template**: +```twig +
+

Benchmark Dashboard

+ + {# Real-time progress component #} + {{ component('BenchmarkProgress') }} + + {# Existing dashboard content #} + {{ component('BenchmarkList') }} +
+``` + +**Behavior**: +- Page loads → Component shows "Waiting to start..." +- Benchmark starts → Shows progress bar at 0% +- Each iteration → Progress bar updates (20%, 40%, 60%, 80%, 100%) +- Completion → Shows "Completed! X iterations finished." + +--- + +## Architecture Compliance + +### Clean Architecture ✅ + +**Domain Layer** (pure business logic): +- Events: `BenchmarkStarted`, `BenchmarkProgress`, `BenchmarkCompleted` +- No dependencies on infrastructure + +**Application Layer** (use cases): +- `AsyncBenchmarkRunner` dispatches Domain events +- Uses `EventDispatcherInterface` (abstraction) + +**Infrastructure Layer** (technical details): +- `BenchmarkProgressSubscriber` handles events +- `MercureHub` publishes to external service +- Live Components & Stimulus for frontend + +**Dependency Rule**: Infrastructure → Application → Domain ✅ + +--- + +### DDD Patterns ✅ + +**Domain Events**: Express business facts +- "A benchmark started" +- "Progress was made" +- "Benchmark completed" + +**Event Sourcing** (partial): Events track execution lifecycle + +**Ports & Adapters**: +- **Port**: `EventDispatcherInterface` (Symfony) +- **Adapter**: `BenchmarkProgressSubscriber` (our implementation) +- **External System**: Mercure Hub + +--- + +## Performance Considerations + +### Current Behavior + +**Event frequency**: 1 event per iteration +- For 1000 iterations: 1000 progress events +- Potential browser/network overhead + +### Optimization Options + +**1. Throttle progress events** (recommended): +```php +// AsyncBenchmarkRunner.php +if ($completedIterations % 10 === 0) { + $this->eventDispatcher->dispatch(new BenchmarkProgress(...)); +} +``` + +**Benefits**: +- 1000 iterations → 100 events (90% reduction) +- Maintains progress visibility +- Reduces Mercure/browser load + +**2. Batch updates** (alternative): +```php +// Send progress every second instead of every iteration +if (time() - $lastUpdateTime >= 1) { + $this->eventDispatcher->dispatch(new BenchmarkProgress(...)); + $lastUpdateTime = time(); +} +``` + +--- + +## Security + +### Development Mode (Current) + +**Configuration**: +```yaml +MERCURE_EXTRA_DIRECTIVES: | + anonymous +``` + +**Warning**: ⚠️ Anyone can subscribe without authentication + +--- + +### Production Mode (Recommended) + +**1. Remove `anonymous` directive** + +**2. Generate JWT tokens**: +```bash +# Generate subscriber token +docker-compose exec main php bin/console mercure:create-subscriber-token benchmark/progress +``` + +**3. Set cookie in controller**: +```php +use Symfony\Component\Mercure\Authorization; + +public function dashboard(Authorization $authorization): Response +{ + $authorization->setCookie($request, ['benchmark/progress']); + return $this->render('dashboard/index.html.twig'); +} +``` + +**4. Rotate secrets regularly**: +```bash +# Generate new secret +openssl rand -base64 32 + +# Update .env and docker-compose.yml +# Restart services +docker-compose restart mercure main +``` + +--- + +## Troubleshooting + +### Events not appearing in browser + +**Check**: +1. Mercure running: `docker-compose ps mercure` +2. Mercure logs: `docker-compose logs -f mercure` +3. Browser console for EventSource errors +4. Network tab for `/mercure` requests + +**Solution**: +```bash +# Restart Mercure +docker-compose restart mercure + +# Check CORS config +docker-compose exec mercure cat /etc/caddy/dev.Caddyfile +``` + +--- + +### CORS errors + +**Symptom**: `Access-Control-Allow-Origin` error in browser console + +**Solution**: Update `docker-compose.yml:157`: +```yaml +cors_origins http://localhost:8000 http://127.0.0.1:8000 https://yourdomain.com +``` + +--- + +### High CPU usage + +**Cause**: Too many progress events (1 per iteration) + +**Solution**: Implement throttling (see Performance Considerations above) + +--- + +## Next Steps + +### Immediate + +- ✅ All features implemented and tested +- ✅ Documentation complete +- ✅ Code style validated (PHP-CS-Fixer) + +### Future Enhancements + +1. **Throttle progress events** (reduce network load) +2. **Add authentication** (production security) +3. **Create dedicated progress dashboard** (full-page view) +4. **Add sound notifications** (completion alerts) +5. **Store event history** (replay capability) + +--- + +## Resources + +- [Mercure Protocol](https://mercure.rocks/) +- [Symfony Mercure Bundle](https://symfony.com/bundles/MercureBundle/current/index.html) +- [Symfony UX Live Components](https://symfony.com/bundles/ux-live-component/current/index.html) +- [Server-Sent Events (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) + +--- + +**Implementation by**: Claude Code +**Review status**: Tested and verified +**Production ready**: Yes (with security hardening) diff --git a/docs/infrastructure/mercure-index.md b/docs/infrastructure/mercure-index.md new file mode 100644 index 0000000..3dc3852 --- /dev/null +++ b/docs/infrastructure/mercure-index.md @@ -0,0 +1,457 @@ +# Mercure Documentation Index + +Complete guide to real-time benchmark progress with Mercure. + +## 📚 Documentation Structure + +``` +docs/infrastructure/ +├── mercure-realtime.md # Architecture & Configuration (Complete) +├── mercure-practical-guide.md # Debugging & Usage (Hands-on) +├── mercure-implementation-summary.md # What was built (Summary) +└── mercure-index.md # This file (Navigation) + +scripts/ +├── mercure-verify.sh # Configuration checker +├── mercure-listen.sh # Event listener with formatting +├── mercure-test.sh # End-to-end automated test +└── README.md # Scripts documentation +``` + +## 🎯 Quick Start + +**I'm new to Mercure**: +1. Read: [mercure-realtime.md](mercure-realtime.md) - Section "Overview" +2. Run: `./scripts/mercure-verify.sh` +3. Test: `./scripts/mercure-test.sh` + +**I want to see it working**: +1. Terminal 1: `./scripts/mercure-listen.sh` +2. Terminal 2: `make run test=Loop iterations=5 version=php84` +3. Watch events appear in Terminal 1 in real-time! + +**I have problems**: +1. Read: [mercure-practical-guide.md](mercure-practical-guide.md) - Section "Troubleshooting Workflows" +2. Run: `./scripts/mercure-verify.sh` to diagnose issues +3. Check logs: `docker-compose logs -f mercure` + +## 📖 Documentation Guide + +### 1. Architecture & Configuration +**File**: [mercure-realtime.md](mercure-realtime.md) + +**Read this if**: +- You want to understand how Mercure works +- You need to configure Mercure +- You're implementing similar features + +**Contents**: +- Complete architecture diagrams +- Event flow explanation +- Configuration reference +- Security recommendations +- Performance considerations + +**Best sections**: +- [Architecture](mercure-realtime.md#architecture) - Visual diagrams +- [Configuration](mercure-realtime.md#configuration) - Environment setup +- [Event Flow](mercure-realtime.md#event-flow) - Step-by-step execution + +--- + +### 2. Debugging & Practical Usage +**File**: [mercure-practical-guide.md](mercure-practical-guide.md) + +**Read this if**: +- Events are not appearing in browser +- You want to verify Mercure is working +- You need debugging techniques +- You want practical examples + +**Contents**: +- 9 debugging techniques +- Complete verification checklist +- 5 practical use cases +- Useful commands by category +- Troubleshooting workflows + +**Best sections**: +- [How to Debug Mercure](mercure-practical-guide.md#how-to-debug-mercure) - 9 techniques +- [Common Use Cases](mercure-practical-guide.md#common-use-cases) - Real examples +- [Useful Commands](mercure-practical-guide.md#useful-commands) - Command reference + +--- + +### 3. Implementation Summary +**File**: [mercure-implementation-summary.md](mercure-implementation-summary.md) + +**Read this if**: +- You want a high-level overview +- You need to know what was built +- You're reviewing the implementation + +**Contents**: +- What was implemented +- Files created/modified +- Testing results +- Performance notes +- Future enhancements + +**Best sections**: +- [What Was Implemented](mercure-implementation-summary.md#what-was-implemented) +- [Testing](mercure-implementation-summary.md#testing) +- [Architecture Compliance](mercure-implementation-summary.md#architecture-compliance) + +--- + +### 4. Docker Infrastructure +**File**: [docker.md](docker.md#real-time-updates) + +**Read this if**: +- You need Docker-specific Mercure info +- You're setting up the infrastructure +- You want to understand service architecture + +**Contents**: +- Mercure service configuration +- Docker Compose setup +- Port mappings +- Volume configuration + +**Best section**: +- [Real-Time Updates](docker.md#real-time-updates) - Mercure service details + +--- + +## 🛠️ Scripts Guide + +### Quick Reference + +| Script | Purpose | Usage | +|--------|---------|-------| +| **mercure-verify.sh** | Health check | `./scripts/mercure-verify.sh` | +| **mercure-listen.sh** | Watch events | `./scripts/mercure-listen.sh [topic] [format]` | +| **mercure-test.sh** | E2E test | `./scripts/mercure-test.sh [iterations] [test] [version]` | + +### mercure-verify.sh +✅ **Configuration checker** - Verifies Mercure setup + +**When to use**: +- After initial setup +- When events stop working +- Before debugging + +**What it checks** (15+ validations): +- Container status +- Port accessibility +- Environment variables +- Event subscriber registration +- CORS configuration +- Log errors + +**Example**: +```bash +./scripts/mercure-verify.sh + +# Output: +# ╔════════════════════════════════════════════════════════════════╗ +# ║ Mercure Configuration Verification ║ +# ╚════════════════════════════════════════════════════════════════╝ +# +# 1. Checking Mercure container status... +# ✅ Mercure container is running +# ... +# Summary: 15 passed, 0 failed +# 🎉 All checks passed! +``` + +--- + +### mercure-listen.sh +👂 **Event listener** - Watch real-time events with formatting + +**When to use**: +- Debug event publishing +- Monitor benchmark execution +- Verify events are sent correctly + +**Formats**: +- `pretty` - Colored, formatted output (default) +- `json` - JSON only (requires jq) +- `raw` - Raw SSE format +- `stats` - Event count statistics + +**Examples**: + +**Watch with pretty formatting**: +```bash +./scripts/mercure-listen.sh + +# Output: +# 📨 Event ID: bc8bc7a8-e0c5-40af-8547-dd21fc4a7306 +# ⏱️ benchmark.started at 12:34:56 +# Benchmark: Loop on php84 (5 iterations) +# +# 🔄 benchmark.progress at 12:34:57 +# Loop: 1/5 (20%) +``` + +**JSON format** (for parsing): +```bash +./scripts/mercure-listen.sh "" json + +# Output: +# { +# "type": "benchmark.progress", +# "benchmarkName": "Loop", +# "currentIteration": 1, +# "totalIterations": 5, +# "progress": 20 +# } +``` + +**Statistics mode**: +```bash +./scripts/mercure-listen.sh "" stats + +# Output (updates in real-time): +# Started: 1 | Progress: 5 | Completed: 1 +``` + +--- + +### mercure-test.sh +🧪 **End-to-end test** - Automated validation + +**When to use**: +- Verify complete workflow +- CI/CD pipeline testing +- Regression testing + +**What it does**: +1. Starts event listener in background +2. Runs benchmark +3. Captures all events +4. Validates event counts +5. Shows results + +**Examples**: + +**Quick test** (3 iterations): +```bash +./scripts/mercure-test.sh + +# Output: +# ╔════════════════════════════════════════════════════════════════╗ +# ║ Mercure End-to-End Test ║ +# ╚════════════════════════════════════════════════════════════════╝ +# +# Events received: +# 📨 Total events: 5 +# 🚀 Started: 1 +# 🔄 Progress: 3 +# ✅ Completed: 1 +# +# 🎉 All tests passed! +``` + +**Custom test**: +```bash +./scripts/mercure-test.sh 10 Loop php84 +# 10 iterations, Loop benchmark, PHP 8.4 +``` + +--- + +## 🚀 Common Workflows + +### Initial Setup + +```bash +# 1. Start services +docker-compose up -d + +# 2. Verify Mercure +./scripts/mercure-verify.sh + +# 3. Test end-to-end +./scripts/mercure-test.sh + +# 4. If all passes, you're ready! +``` + +--- + +### Development Workflow + +```bash +# Terminal 1: Watch events +./scripts/mercure-listen.sh + +# Terminal 2: Run benchmarks +make run test=Loop iterations=10 version=php84 + +# Terminal 1 shows real-time progress +``` + +--- + +### Debugging Workflow + +```bash +# Step 1: Verify configuration +./scripts/mercure-verify.sh + +# Step 2: Check if events are published +./scripts/mercure-listen.sh "" raw & +make run test=Loop iterations=3 version=php84 + +# Step 3: If no events, check logs +docker-compose logs -f mercure +docker-compose logs -f main + +# Step 4: Check event subscriber +docker-compose exec main php bin/console debug:event-dispatcher | grep Benchmark +``` + +--- + +### CI/CD Integration + +```yaml +# .github/workflows/test.yml +jobs: + test-mercure: + runs-on: ubuntu-latest + steps: + - name: Start services + run: docker-compose up -d + + - name: Verify Mercure + run: ./scripts/mercure-verify.sh + + - name: Run E2E test + run: ./scripts/mercure-test.sh 5 Loop php84 + + - name: Check results + run: | + if [ $? -eq 0 ]; then + echo "✅ Mercure tests passed" + else + echo "❌ Mercure tests failed" + exit 1 + fi +``` + +--- + +## 🔍 Troubleshooting Index + +### By Symptom + +| Symptom | Check | Fix | +|---------|-------|-----| +| No events in browser | [Debug #1](mercure-practical-guide.md#1-check-mercure-service-is-running) | `./scripts/mercure-verify.sh` | +| CORS errors | [Debug #7](mercure-practical-guide.md#7-check-mercure-logs-for-errors) | Update `docker-compose.yml:157` | +| Events delayed | [Troubleshooting](mercure-practical-guide.md#problem-events-are-delayed-or-batched) | Check network/buffering | +| Too many events | [Troubleshooting](mercure-practical-guide.md#problem-too-many-events-high-cpu) | Throttle events | +| Container not starting | [Docker Guide](docker.md#troubleshooting) | `docker-compose logs mercure` | + +### By Component + +| Component | Documentation | Debug Command | +|-----------|---------------|---------------| +| Mercure Hub | [Docker](docker.md#mercure-service) | `docker-compose logs mercure` | +| Event Publishing | [Architecture](mercure-realtime.md#event-subscriber) | Check subscriber logs | +| Browser Connection | [Practical Guide](mercure-practical-guide.md#8-test-with-browser-devtools) | Browser DevTools Network tab | +| Configuration | [Configuration](mercure-realtime.md#configuration) | `./scripts/mercure-verify.sh` | + +--- + +## 📊 Event Reference + +### Event Types + +| Type | When | Data Included | +|------|------|---------------| +| `benchmark.started` | Benchmark execution begins | benchmarkId, benchmarkName, phpVersion, totalIterations | +| `benchmark.progress` | Each iteration completes | currentIteration, totalIterations, progress (%) | +| `benchmark.completed` | All iterations done | totalIterations, timestamp | + +### Topics + +| Topic | Events | Usage | +|-------|--------|-------| +| `benchmark/progress` | All 3 event types | Main topic for progress tracking | +| `benchmark/results` | Only completed events | Final results (not used currently) | + +### Event Structure + +```json +{ + "type": "benchmark.progress", + "benchmarkId": "Jblairy\\PhpBenchmark\\Domain\\Benchmark\\Test\\Loop", + "benchmarkName": "Loop", + "phpVersion": "php84", + "currentIteration": 5, + "totalIterations": 10, + "progress": 50, + "timestamp": 1761113607 +} +``` + +--- + +## 🔗 Quick Links + +### Documentation +- [Complete Architecture Guide](mercure-realtime.md) +- [Debugging & Practical Usage](mercure-practical-guide.md) +- [Implementation Summary](mercure-implementation-summary.md) +- [Docker Setup](docker.md#real-time-updates) +- [Scripts Documentation](../../scripts/README.md) + +### Code +- Events: `src/Domain/Benchmark/Event/` +- Subscriber: `src/Infrastructure/Mercure/EventSubscriber/BenchmarkProgressSubscriber.php` +- Use Case: `src/Application/UseCase/AsyncBenchmarkRunner.php` +- Live Component: `src/Infrastructure/Web/Component/BenchmarkProgressComponent.php` + +### External Resources +- [Mercure Protocol](https://mercure.rocks/) +- [Symfony Mercure Bundle](https://symfony.com/bundles/MercureBundle/current/index.html) +- [Server-Sent Events (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) + +--- + +## ✅ Checklist + +### For Developers + +- [ ] Read architecture overview +- [ ] Understand event flow +- [ ] Run verification script +- [ ] Test with listener script +- [ ] Know how to debug + +### For Operations + +- [ ] Verify Mercure is running +- [ ] Check CORS configuration +- [ ] Monitor Mercure logs +- [ ] Know troubleshooting steps +- [ ] Have backup plan if Mercure fails + +### For Testing + +- [ ] Run automated E2E test +- [ ] Verify all event types +- [ ] Check event counts +- [ ] Test error scenarios +- [ ] Performance test with many iterations + +--- + +**Last updated**: 2025-10-22 +**Status**: Complete and tested +**Maintained by**: Project contributors diff --git a/docs/infrastructure/mercure-practical-guide.md b/docs/infrastructure/mercure-practical-guide.md new file mode 100644 index 0000000..11ecd69 --- /dev/null +++ b/docs/infrastructure/mercure-practical-guide.md @@ -0,0 +1,1011 @@ +# Mercure Practical Guide - Debugging & Usage + +This practical guide explains how to work with Mercure in the PHP Benchmark project, including debugging techniques, verification steps, and common usage scenarios. + +## Table of Contents + +1. [What We Built](#what-we-built) +2. [How to Debug Mercure](#how-to-debug-mercure) +3. [Verification Checklist](#verification-checklist) +4. [Common Use Cases](#common-use-cases) +5. [Useful Commands](#useful-commands) +6. [Troubleshooting Workflows](#troubleshooting-workflows) + +--- + +## What We Built + +### Implementation Summary + +**Objective**: Display real-time benchmark progress in the browser without page refresh + +**Technology Stack**: +- **Mercure Hub**: Server-Sent Events (SSE) broadcasting server +- **Symfony Events**: Domain events dispatched during benchmark execution +- **Event Subscriber**: Publishes events to Mercure +- **Live Components**: Reactive UI components +- **Stimulus**: JavaScript for EventSource connection + +### Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Browser (http://localhost:8000) │ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Live Component: BenchmarkProgress │ │ +│ │ - Displays progress bar │ │ +│ │ - Shows current iteration │ │ +│ │ - Updates automatically │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ ▲ │ +│ │ SSE (Server-Sent Events) │ +│ │ │ +└─────────────────────┼─────────────────────────────────────────┘ + │ +┌─────────────────────┼─────────────────────────────────────────┐ +│ │ │ +│ ┌──────────────────┴───────────────────────────────────┐ │ +│ │ Mercure Hub (http://localhost:3000) │ │ +│ │ - Receives updates from Symfony │ │ +│ │ - Broadcasts to all connected browsers │ │ +│ │ - Topic: "benchmark/progress" │ │ +│ └──────────────────▲───────────────────────────────────┘ │ +│ │ │ +│ │ HTTP POST │ +│ │ │ +│ ┌──────────────────┴───────────────────────────────────┐ │ +│ │ Symfony Application │ │ +│ │ │ │ +│ │ 1. AsyncBenchmarkRunner │ │ +│ │ - Dispatches: BenchmarkStarted │ │ +│ │ - Dispatches: BenchmarkProgress (each iteration) │ │ +│ │ - Dispatches: BenchmarkCompleted │ │ +│ │ │ │ +│ │ 2. BenchmarkProgressSubscriber │ │ +│ │ - Listens to Domain events │ │ +│ │ - Publishes to Mercure Hub │ │ +│ │ - Format: JSON over HTTP │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +└───────────────────────────────────────────────────────────────┘ +``` + +### What Happens Step-by-Step + +**1. User opens dashboard in browser** +``` +Browser → http://localhost:8000/dashboard +BenchmarkProgress component loads +Stimulus controller connects to Mercure +EventSource opens: http://localhost:3000/.well-known/mercure?topic=benchmark/progress +``` + +**2. User runs benchmark from CLI** +```bash +make run test=Loop iterations=10 version=php84 +``` + +**3. AsyncBenchmarkRunner dispatches events** +``` +Iteration 0: BenchmarkStarted event +Iteration 1: BenchmarkProgress event (10%) +Iteration 2: BenchmarkProgress event (20%) +... +Iteration 10: BenchmarkProgress event (100%) +After pool.wait(): BenchmarkCompleted event +``` + +**4. BenchmarkProgressSubscriber publishes to Mercure** +```php +// For each event +$update = new Update( + 'benchmark/progress', + json_encode([ + 'type' => 'benchmark.progress', + 'benchmarkId' => 'Loop', + 'currentIteration' => 5, + 'totalIterations' => 10, + 'progress' => 50 + ]) +); +$hub->publish($update); +``` + +**5. Mercure broadcasts to all subscribers** +``` +POST http://mercure/.well-known/mercure +Topic: benchmark/progress +Data: JSON event + +Mercure → All connected EventSource clients +``` + +**6. Browser receives SSE and updates UI** +```javascript +eventSource.onmessage = (event) => { + const data = JSON.parse(event.data); + // Update progress bar: 10% → 20% → 30% → ... → 100% +}; +``` + +--- + +## How to Debug Mercure + +### 1. Check Mercure Service is Running + +**Command**: +```bash +docker-compose ps mercure +``` + +**Expected output**: +``` +NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS +php_benchmark-mercure-1 dunglas/mercure "/usr/bin/caddy run …" mercure 10 minutes ago Up 10 minutes 443/tcp, 2019/tcp, 0.0.0.0:3000->80/tcp +``` + +**What to check**: +- ✅ **STATUS**: Should be "Up" (not "Exited" or "Restarting") +- ✅ **PORTS**: Should show `0.0.0.0:3000->80/tcp` + +**If not running**: +```bash +# Start Mercure +docker-compose up -d mercure + +# Check logs +docker-compose logs -f mercure +``` + +--- + +### 2. Verify Mercure is Accessible + +**Command**: +```bash +curl -i http://localhost:3000/.well-known/mercure +``` + +**Expected output**: +```http +HTTP/1.1 400 Bad Request +Server: Caddy +Content-Type: text/plain; charset=utf-8 + +Missing "topic" parameter +``` + +**Interpretation**: +- ✅ **400 Bad Request** is NORMAL (we didn't provide a topic) +- ✅ **Server: Caddy** confirms Mercure is responding +- ❌ **Connection refused** means Mercure is not accessible + +--- + +### 3. Subscribe to Events (Manual Test) + +**Command** (keeps running, press Ctrl+C to stop): +```bash +curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" +``` + +**Expected behavior**: +- Command hangs (this is normal - it's waiting for events) +- No output initially +- When you run a benchmark, you'll see SSE messages + +**Sample output when benchmark runs**: +``` +id: urn:uuid:bc8bc7a8-e0c5-40af-8547-dd21fc4a7306 +data: {"type":"benchmark.started","benchmarkId":"Jblairy\\PhpBenchmark\\Domain\\Benchmark\\Test\\Loop","benchmarkName":"Loop","phpVersion":"php84","totalIterations":5,"timestamp":1761113607} + +id: urn:uuid:11031f73-897a-4d97-bf13-debe15b5ef20 +data: {"type":"benchmark.progress","benchmarkId":"Jblairy\\PhpBenchmark\\Domain\\Benchmark\\Test\\Loop","benchmarkName":"Loop","phpVersion":"php84","currentIteration":1,"totalIterations":5,"progress":20,"timestamp":1761113608} +``` + +**SSE format explained**: +- `id:` - Unique event identifier (UUID) +- `data:` - JSON payload with event information +- Blank line separates events + +--- + +### 4. Check Event Publishing from Symfony + +**Command** (check if subscriber is registered): +```bash +docker-compose exec main php bin/console debug:event-dispatcher +``` + +**Look for**: +``` +Jblairy\PhpBenchmark\Domain\Benchmark\Event\BenchmarkStarted + - Jblairy\PhpBenchmark\Infrastructure\Mercure\EventSubscriber\BenchmarkProgressSubscriber::onBenchmarkStarted + +Jblairy\PhpBenchmark\Domain\Benchmark\Event\BenchmarkProgress + - Jblairy\PhpBenchmark\Infrastructure\Mercure\EventSubscriber\BenchmarkProgressSubscriber::onBenchmarkProgress + +Jblairy\PhpBenchmark\Domain\Benchmark\Event\BenchmarkCompleted + - Jblairy\PhpBenchmark\Infrastructure\Mercure\EventSubscriber\BenchmarkProgressSubscriber::onBenchmarkCompleted +``` + +**Interpretation**: +- ✅ All 3 events should have `BenchmarkProgressSubscriber` handlers +- ❌ If missing, subscriber is not registered (check autowiring) + +--- + +### 5. Test End-to-End Publishing + +**Terminal 1** (subscribe to Mercure): +```bash +curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" +``` + +**Terminal 2** (run a short benchmark): +```bash +docker-compose exec main php bin/console benchmark:run --test=Loop --php-version=php84 --iterations=3 +``` + +**Expected flow**: +1. Terminal 2 shows: "Running Loop on php84 (3 iterations)" +2. Terminal 1 receives 5 SSE events: + - 1 `benchmark.started` + - 3 `benchmark.progress` (33%, 66%, 100%) + - 1 `benchmark.completed` +3. Terminal 2 shows: "[OK] Benchmark(s) completed successfully!" + +**If no events in Terminal 1**: +- Check Mercure logs: `docker-compose logs -f mercure` +- Check Symfony logs: `docker-compose logs -f main` +- Verify environment variables (see section below) + +--- + +### 6. Check Environment Variables + +**Command**: +```bash +docker-compose exec main env | grep MERCURE +``` + +**Expected output**: +``` +MERCURE_URL=http://mercure/.well-known/mercure +MERCURE_PUBLIC_URL=http://localhost:3000/.well-known/mercure +MERCURE_JWT_SECRET=!ChangeThisMercureHubJWTSecretKey! +``` + +**Verify in docker-compose.yml**: +```bash +grep -A 5 "MERCURE_PUBLISHER_JWT_KEY" docker-compose.yml +``` + +**Expected**: +```yaml +MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' +MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' +``` + +**Critical**: `MERCURE_JWT_SECRET` must match `MERCURE_PUBLISHER_JWT_KEY` + +--- + +### 7. Check Mercure Logs for Errors + +**Command**: +```bash +docker-compose logs -f mercure +``` + +**Look for**: +- ✅ `"server running"` - Mercure started successfully +- ✅ `"handled request"` - Mercure is processing requests +- ❌ `"authentication failed"` - JWT secret mismatch +- ❌ `"cors error"` - CORS configuration issue + +**Example healthy log**: +```json +{"level":"info","msg":"server running","name":"srv0","protocols":["h1"]} +{"level":"info","msg":"handled request","method":"GET","uri":"/.well-known/mercure","status":200} +``` + +--- + +### 8. Test with Browser DevTools + +**Steps**: +1. Open browser to `http://localhost:8000` +2. Open DevTools (F12) +3. Go to **Network** tab +4. Filter: `mercure` +5. Run a benchmark + +**What to look for**: + +**Request**: +``` +GET http://localhost:3000/.well-known/mercure?topic=benchmark%2Fprogress +Status: 200 OK +Type: eventsource +``` + +**Response headers**: +``` +Content-Type: text/event-stream +Cache-Control: no-cache +Connection: keep-alive +``` + +**EventStream tab** (in Chrome): +``` +id: urn:uuid:... +data: {"type":"benchmark.started",...} + +id: urn:uuid:... +data: {"type":"benchmark.progress",...} +``` + +**Console** (check for errors): +- ❌ `CORS error` - Check CORS configuration +- ❌ `EventSource failed` - Check Mercure URL +- ✅ No errors - Working correctly + +--- + +### 9. Debug Symfony Event Publishing + +**Add temporary logging in BenchmarkProgressSubscriber**: + +```php +// src/Infrastructure/Mercure/EventSubscriber/BenchmarkProgressSubscriber.php + +use Psr\Log\LoggerInterface; + +public function __construct( + private HubInterface $hub, + private LoggerInterface $logger, // Add this +) {} + +public function onBenchmarkProgress(BenchmarkProgress $event): void +{ + $this->logger->info('Publishing benchmark progress', [ + 'benchmarkId' => $event->benchmarkId, + 'currentIteration' => $event->currentIteration, + 'totalIterations' => $event->totalIterations, + ]); + + $this->publishUpdate('benchmark/progress', $event->toArray()); +} +``` + +**Check logs**: +```bash +docker-compose exec main tail -f var/log/dev.log +``` + +**Expected**: +``` +[info] Publishing benchmark progress {"benchmarkId":"Loop","currentIteration":1,"totalIterations":5} +[info] Publishing benchmark progress {"benchmarkId":"Loop","currentIteration":2,"totalIterations":5} +``` + +--- + +## Verification Checklist + +Use this checklist to verify Mercure is working correctly: + +### Infrastructure +- [ ] Mercure container running: `docker-compose ps mercure` +- [ ] Port 3000 exposed: `curl http://localhost:3000/.well-known/mercure` +- [ ] No errors in Mercure logs: `docker-compose logs mercure` + +### Configuration +- [ ] Environment variables set: `docker-compose exec main env | grep MERCURE` +- [ ] JWT secrets match (`.env` vs `docker-compose.yml`) +- [ ] CORS configured: `grep cors_origins docker-compose.yml` + +### Symfony Integration +- [ ] Event subscriber registered: `php bin/console debug:event-dispatcher` +- [ ] HubInterface autowired: `php bin/console debug:autowiring HubInterface` +- [ ] Mercure bundle configured: `cat config/packages/mercure.yaml` + +### Publishing +- [ ] Events dispatched during benchmark: Check logs +- [ ] Subscriber publishes to Mercure: Add logging +- [ ] Mercure receives updates: Monitor logs + +### Browser +- [ ] EventSource connects: Check Network tab +- [ ] SSE messages received: Check EventStream tab +- [ ] UI updates automatically: Run benchmark and watch +- [ ] No CORS errors: Check Console tab + +--- + +## Common Use Cases + +### Use Case 1: Monitor Single Benchmark in Real-Time + +**Scenario**: You're optimizing a specific benchmark and want to see progress live + +**Steps**: + +1. **Open dashboard in browser**: + ``` + http://localhost:8000 + ``` + +2. **Add progress component** (if not already in template): + ```twig + {{ component('BenchmarkProgress') }} + ``` + +3. **Run benchmark with many iterations**: + ```bash + make run test=Loop iterations=100 version=php84 + ``` + +4. **Watch the UI**: + - Progress bar fills from 0% → 100% + - Shows "X / 100" iterations + - Completes with "Completed! 100 iterations finished." + +**Expected time**: ~30 seconds for 100 iterations + +--- + +### Use Case 2: Debug Benchmark Execution + +**Scenario**: Benchmark is taking too long, you want to see if it's stuck + +**Terminal 1** (watch events): +```bash +curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" | \ + jq -R 'select(startswith("data:")) | sub("^data: "; "") | fromjson' +``` + +**Terminal 2** (run benchmark): +```bash +make run test=YourSlowBenchmark iterations=50 version=php84 +``` + +**Terminal 1 output** (formatted JSON): +```json +{"type":"benchmark.started","benchmarkName":"YourSlowBenchmark","totalIterations":50} +{"type":"benchmark.progress","currentIteration":1,"progress":2} +{"type":"benchmark.progress","currentIteration":2,"progress":4} +... (wait) ... +{"type":"benchmark.progress","currentIteration":3,"progress":6} +``` + +**Interpretation**: +- If events stop coming → Benchmark is stuck +- If events are slow but regular → Benchmark is just slow +- Check time between events to measure iteration speed + +--- + +### Use Case 3: Compare Multiple PHP Versions + +**Scenario**: Run same benchmark on all PHP versions and monitor progress + +**Terminal 1** (subscribe): +```bash +# Pretty-print with jq for readability +curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" | \ + while IFS= read -r line; do + if [[ $line == data:* ]]; then + echo "$line" | sed 's/^data: //' | jq -c '{type,phpVersion,progress}' + fi + done +``` + +**Terminal 2** (run on all versions): +```bash +make run test=Loop iterations=10 +``` + +**Terminal 1 output**: +```json +{"type":"benchmark.started","phpVersion":"php56","progress":null} +{"type":"benchmark.progress","phpVersion":"php56","progress":10} +{"type":"benchmark.progress","phpVersion":"php56","progress":20} +... +{"type":"benchmark.completed","phpVersion":"php56","progress":null} +{"type":"benchmark.started","phpVersion":"php70","progress":null} +{"type":"benchmark.progress","phpVersion":"php70","progress":10} +... +``` + +**See progression across all PHP versions in real-time** + +--- + +### Use Case 4: Automated Testing with Event Capture + +**Scenario**: CI/CD pipeline that validates benchmarks complete successfully + +**Script** (`test-benchmark-events.sh`): +```bash +#!/bin/bash + +# Start listening to Mercure in background +timeout 60 curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" > /tmp/events.log 2>&1 & +CURL_PID=$! + +# Give curl time to connect +sleep 2 + +# Run benchmark +docker-compose exec -T main php bin/console benchmark:run --test=Loop --php-version=php84 --iterations=5 + +# Wait for events to be captured +sleep 3 + +# Kill curl +kill $CURL_PID 2>/dev/null + +# Parse events +STARTED=$(grep -c '"benchmark.started"' /tmp/events.log) +PROGRESS=$(grep -c '"benchmark.progress"' /tmp/events.log) +COMPLETED=$(grep -c '"benchmark.completed"' /tmp/events.log) + +echo "Events received:" +echo " Started: $STARTED" +echo " Progress: $PROGRESS" +echo " Completed: $COMPLETED" + +# Validate +if [ "$STARTED" -eq 1 ] && [ "$PROGRESS" -eq 5 ] && [ "$COMPLETED" -eq 1 ]; then + echo "✅ All events received correctly" + exit 0 +else + echo "❌ Missing events" + exit 1 +fi +``` + +**Usage**: +```bash +chmod +x test-benchmark-events.sh +./test-benchmark-events.sh +``` + +--- + +### Use Case 5: Live Dashboard for Team + +**Scenario**: Team dashboard showing benchmark progress on a TV screen + +**Create dedicated route** (`src/Infrastructure/Web/Controller/LiveDashboardController.php`): +```php +render('live_dashboard/index.html.twig'); + } +} +``` + +**Template** (`templates/live_dashboard/index.html.twig`): +```twig + + + + Live Benchmark Dashboard + + + +

🚀 Live Benchmark Progress

+ +
+ {{ component('BenchmarkProgress') }} +
+ + {# Auto-refresh every hour to prevent stale connections #} + + + +``` + +**Access**: `http://localhost:8000/live` + +**Display on TV**: Full-screen browser, shows all benchmarks running in real-time + +--- + +## Useful Commands + +### Mercure Management + +**Start Mercure**: +```bash +docker-compose up -d mercure +``` + +**Stop Mercure**: +```bash +docker-compose stop mercure +``` + +**Restart Mercure** (after config changes): +```bash +docker-compose restart mercure +``` + +**View Mercure logs** (follow mode): +```bash +docker-compose logs -f mercure +``` + +**View last 50 lines**: +```bash +docker-compose logs --tail=50 mercure +``` + +**Check Mercure resource usage**: +```bash +docker stats php_benchmark-mercure-1 +``` + +--- + +### Testing & Debugging + +**Subscribe to events** (basic): +```bash +curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" +``` + +**Subscribe with JSON formatting** (requires `jq`): +```bash +curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" | \ + grep '^data:' | sed 's/^data: //' | jq . +``` + +**Count events received**: +```bash +timeout 30 curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" | \ + grep -c '^data:' +``` + +**Extract only progress percentages**: +```bash +curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" | \ + grep '^data:' | sed 's/^data: //' | jq -r '.progress // empty' +``` + +**Monitor events with timestamps**: +```bash +curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" | \ + while IFS= read -r line; do + if [[ $line == data:* ]]; then + echo "[$(date '+%H:%M:%S')] $line" + fi + done +``` + +--- + +### Benchmark Execution + +**Run single benchmark with progress**: +```bash +make run test=Loop iterations=10 version=php84 +``` + +**Run all benchmarks** (watch progress for each): +```bash +make run iterations=5 +``` + +**Run specific test on all PHP versions**: +```bash +# Terminal 1: Watch events +curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" + +# Terminal 2: Run benchmark +docker-compose exec main php bin/console benchmark:run --test=Loop --iterations=5 +``` + +--- + +### Environment & Configuration + +**Check Mercure environment variables**: +```bash +docker-compose exec main env | grep MERCURE +``` + +**Validate Mercure config**: +```bash +docker-compose exec main php bin/console debug:config mercure +``` + +**Check event subscribers**: +```bash +docker-compose exec main php bin/console debug:event-dispatcher | grep Benchmark +``` + +**Verify HubInterface autowiring**: +```bash +docker-compose exec main php bin/console debug:autowiring HubInterface +``` + +--- + +### Performance Monitoring + +**Monitor Mercure CPU/Memory**: +```bash +watch -n 1 'docker stats --no-stream php_benchmark-mercure-1' +``` + +**Count active SSE connections** (check Mercure logs): +```bash +docker-compose logs mercure | grep -c "subscribe" +``` + +**Measure event throughput**: +```bash +# Run benchmark +time make run test=Loop iterations=100 version=php84 + +# Count events published +docker-compose logs mercure | grep -c "POST /.well-known/mercure" +``` + +--- + +### Data Analysis + +**Extract all events to file**: +```bash +timeout 60 curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" > benchmark_events.txt +``` + +**Parse events to CSV**: +```bash +cat benchmark_events.txt | \ + grep '^data:' | \ + sed 's/^data: //' | \ + jq -r '[.type, .benchmarkName, .phpVersion, .currentIteration, .totalIterations, .progress] | @csv' +``` + +**Count events by type**: +```bash +cat benchmark_events.txt | \ + grep '^data:' | \ + sed 's/^data: //' | \ + jq -r '.type' | \ + sort | uniq -c +``` + +**Expected output**: +``` + 1 benchmark.completed + 5 benchmark.progress + 1 benchmark.started +``` + +--- + +## Troubleshooting Workflows + +### Problem: No events received in browser + +**Diagnosis workflow**: + +```bash +# Step 1: Check Mercure is running +docker-compose ps mercure +# Expected: Status "Up" + +# Step 2: Check Mercure is accessible +curl -i http://localhost:3000/.well-known/mercure +# Expected: 400 Bad Request (missing topic) + +# Step 3: Subscribe manually +curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" & +CURL_PID=$! + +# Step 4: Run benchmark +make run test=Loop iterations=3 version=php84 + +# Step 5: Check if events appeared +# If yes: Problem is in browser/JavaScript +# If no: Problem is in Symfony publishing + +# Step 6: Kill curl +kill $CURL_PID +``` + +**If events don't appear**: + +```bash +# Check Symfony is dispatching events +docker-compose exec main php bin/console debug:event-dispatcher | grep BenchmarkProgress + +# Check subscriber is registered +docker-compose logs main | grep "Publishing benchmark" + +# Check Mercure logs for POST requests +docker-compose logs mercure | grep POST +``` + +--- + +### Problem: CORS errors in browser + +**Diagnosis**: + +```bash +# Check browser console +# Error: "Access-Control-Allow-Origin" + +# Check Mercure CORS config +docker-compose exec mercure cat /etc/caddy/dev.Caddyfile | grep cors +``` + +**Solution**: + +Update `docker-compose.yml:157`: +```yaml +MERCURE_EXTRA_DIRECTIVES: | + cors_origins http://localhost:8000 http://127.0.0.1:8000 + anonymous +``` + +Then restart: +```bash +docker-compose restart mercure +``` + +**Verify**: +```bash +# Check response headers +curl -i "http://localhost:3000/.well-known/mercure?topic=test" \ + -H "Origin: http://localhost:8000" + +# Should include: +# Access-Control-Allow-Origin: http://localhost:8000 +``` + +--- + +### Problem: Events are delayed or batched + +**Diagnosis**: + +```bash +# Monitor events with timestamps +curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" | \ + while read -r line; do + [[ $line == data:* ]] && echo "[$(date +%H:%M:%S.%N)] $line" + done +``` + +**Expected**: Events appear immediately as benchmark runs + +**If delayed**: +- Check network latency +- Check Mercure logs for slow POST requests +- Check Symfony is not buffering output + +**Solution** (if buffering): +```php +// In BenchmarkProgressSubscriber +private function publishUpdate(string $topic, array $data): void +{ + $update = new Update($topic, json_encode($data)); + $this->hub->publish($update); + + // Force flush (if needed) + if (function_exists('flush')) { + flush(); + } +} +``` + +--- + +### Problem: Too many events (high CPU) + +**Diagnosis**: + +```bash +# Count events during benchmark +curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" | \ + timeout 30 grep -c '^data:' + +# For 100 iterations: +# Expected: ~102 events (1 start + 100 progress + 1 complete) +``` + +**If too many events** (e.g., 1000+ for 100 iterations): + +Check `AsyncBenchmarkRunner.php:54` - should dispatch only once per iteration, not multiple times + +**Solution** (throttle events): +```php +// AsyncBenchmarkRunner.php +if ($completedIterations % 10 === 0 || $completedIterations === $totalIterations) { + $this->eventDispatcher->dispatch(new BenchmarkProgress(...)); +} +``` + +**Result**: 100 iterations → 11 events (10%, 20%, ..., 100%) + +--- + +## Quick Reference Card + +**Common Commands**: + +| Task | Command | +|------|---------| +| Start Mercure | `docker-compose up -d mercure` | +| Check status | `docker-compose ps mercure` | +| View logs | `docker-compose logs -f mercure` | +| Test connection | `curl http://localhost:3000/.well-known/mercure` | +| Subscribe | `curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress"` | +| Run benchmark | `make run test=Loop iterations=5 version=php84` | +| Debug events | `docker-compose exec main php bin/console debug:event-dispatcher` | +| Check config | `docker-compose exec main php bin/console debug:config mercure` | + +**Ports**: +- `3000` - Mercure Hub (SSE) +- `8000` - Symfony application +- `3306` - MariaDB + +**Topics**: +- `benchmark/progress` - All progress events (start, progress, complete) +- `benchmark/results` - Final results only (not used currently) + +**Environment Variables**: +- `MERCURE_URL` - Internal URL (backend → Mercure) +- `MERCURE_PUBLIC_URL` - Public URL (browser → Mercure) +- `MERCURE_JWT_SECRET` - Authentication secret + +--- + +## Further Reading + +- [Mercure Real-Time Guide](mercure-realtime.md) - Complete architecture documentation +- [Docker Infrastructure](docker.md) - Overall Docker setup +- [Mercure Protocol Spec](https://mercure.rocks/spec) - Official specification +- [Server-Sent Events API](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) - Browser API + +--- + +**Last updated**: 2025-10-22 +**Maintained by**: Project contributors diff --git a/docs/infrastructure/mercure-realtime.md b/docs/infrastructure/mercure-realtime.md new file mode 100644 index 0000000..28c2de6 --- /dev/null +++ b/docs/infrastructure/mercure-realtime.md @@ -0,0 +1,658 @@ +# Mercure Real-Time Updates + +This document explains how real-time benchmark progress updates work using Mercure and Live Components. + +## Table of Contents + +1. [Overview](#overview) +2. [Architecture](#architecture) +3. [Components](#components) +4. [Event Flow](#event-flow) +5. [Configuration](#configuration) +6. [Usage](#usage) +7. [Troubleshooting](#troubleshooting) + +## Overview + +The PHP Benchmark project uses **Mercure** for real-time Server-Sent Events (SSE) to display benchmark progress as it happens. When a benchmark runs, the backend publishes progress updates to Mercure, and the frontend automatically receives and displays them using Live Components and Stimulus controllers. + +**Key benefits**: +- ✅ Real-time progress updates without page refresh +- ✅ Live percentage progress bars +- ✅ Instant result display when benchmarks complete +- ✅ Support for concurrent benchmarks +- ✅ No WebSocket complexity (uses HTTP SSE) + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ User Browser │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ Live Component (BenchmarkProgress) │ │ +│ │ │ │ │ +│ │ ├─ Stimulus Controller (mercure-progress) │ │ +│ │ │ └─ EventSource → Mercure Hub (SSE) │ │ +│ │ └─ Updates UI automatically │ │ +│ └────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + ▲ + │ SSE (Server-Sent Events) + │ +┌─────────────────────────────────────────────────────────────────┐ +│ Mercure Hub (Docker) │ +│ - Receives updates from backend │ +│ - Broadcasts to all subscribers │ +│ - Runs on http://localhost:3000 │ +└─────────────────────────────────────────────────────────────────┘ + ▲ + │ HTTP POST (publish updates) + │ +┌─────────────────────────────────────────────────────────────────┐ +│ Symfony Backend │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ AsyncBenchmarkRunner │ │ +│ │ ├─ Dispatches: BenchmarkStarted │ │ +│ │ ├─ Dispatches: BenchmarkProgress (every iteration) │ │ +│ │ └─ Dispatches: BenchmarkCompleted │ │ +│ └────────────┬───────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ BenchmarkProgressSubscriber (Event Listener) │ │ +│ │ └─ Publishes to Mercure Hub │ │ +│ └────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Components + +### Backend Components + +#### 1. Domain Events + +**Location**: `src/Domain/Benchmark/Event/` + +Three events track benchmark lifecycle: + +**BenchmarkStarted** (`BenchmarkStarted.php`) +```php +new BenchmarkStarted( + benchmarkId: 'App\Benchmark\LoopBenchmark', + benchmarkName: 'Loop Performance', + phpVersion: 'php84', + totalIterations: 100 +); +``` + +**BenchmarkProgress** (`BenchmarkProgress.php`) +```php +new BenchmarkProgress( + benchmarkId: 'App\Benchmark\LoopBenchmark', + benchmarkName: 'Loop Performance', + phpVersion: 'php84', + currentIteration: 50, + totalIterations: 100 +); +``` + +**BenchmarkCompleted** (`BenchmarkCompleted.php`) +```php +new BenchmarkCompleted( + benchmarkId: 'App\Benchmark\LoopBenchmark', + benchmarkName: 'Loop Performance', + phpVersion: 'php84', + totalIterations: 100 +); +``` + +**Note**: Statistics (average, p90, p95, p99) are calculated by the `StatisticsCalculator` service and stored in the database. The completion event only signals that all iterations finished. Clients should query the API/database for detailed statistics. + +#### 2. Event Subscriber + +**Location**: `src/Infrastructure/Mercure/EventSubscriber/BenchmarkProgressSubscriber.php` + +Listens to Domain events and publishes to Mercure: + +```php +final readonly class BenchmarkProgressSubscriber implements EventSubscriberInterface +{ + public function __construct(private HubInterface $hub) {} + + public static function getSubscribedEvents(): array + { + return [ + BenchmarkStarted::class => 'onBenchmarkStarted', + BenchmarkProgress::class => 'onBenchmarkProgress', + BenchmarkCompleted::class => 'onBenchmarkCompleted', + ]; + } + + public function onBenchmarkProgress(BenchmarkProgress $event): void + { + $update = new Update( + 'benchmark/progress', + json_encode($event->toArray()) + ); + $this->hub->publish($update); + } +} +``` + +#### 3. Use Case Integration + +**Location**: `src/Application/UseCase/AsyncBenchmarkRunner.php:35` + +The `AsyncBenchmarkRunner` dispatches events at key moments: + +```php +public function run(BenchmarkConfiguration $config): void +{ + // 1. Dispatch start event + $this->eventDispatcher->dispatch(new BenchmarkStarted(...)); + + // 2. Run benchmark iterations + for ($i = 0; $i < $config->iterations; ++$i) { + $pool->add(...)->then(function ($result) { + // 3. Dispatch progress after each iteration + $this->eventDispatcher->dispatch(new BenchmarkProgress(...)); + }); + } + + $pool->wait(); + + // 4. Dispatch completion event + $this->eventDispatcher->dispatch(new BenchmarkCompleted(...)); +} +``` + +### Frontend Components + +#### 1. Live Component + +**Location**: `src/Infrastructure/Web/Component/BenchmarkProgressComponent.php` + +Symfony UX Live Component that renders benchmark progress: + +```php +#[AsLiveComponent('BenchmarkProgress')] +final class BenchmarkProgressComponent +{ + #[LiveProp(writable: true)] + public string $status = 'idle'; + + #[LiveProp(writable: true)] + public int $currentIteration = 0; + + public function getProgress(): int + { + return ($this->currentIteration / $this->totalIterations) * 100; + } +} +``` + +**Template**: `templates/components/BenchmarkProgress.html.twig` + +Displays: +- Progress bar with percentage +- Current iteration / Total iterations +- Benchmark results (average, p90, p95, p99) +- Status badges (idle, running, completed) + +#### 2. Stimulus Controller + +**Location**: `assets/controllers/mercure-progress_controller.js` + +JavaScript controller that: +1. Connects to Mercure EventSource +2. Listens for SSE updates +3. Updates Live Component props +4. Provides immediate DOM updates + +```javascript +export default class extends Controller { + static values = { + url: String, // Mercure hub URL + topic: String // Topic to subscribe to + }; + + connect() { + const url = new URL(this.urlValue); + url.searchParams.append('topic', this.topicValue); + + this.eventSource = new EventSource(url); + this.eventSource.onmessage = (event) => { + const data = JSON.parse(event.data); + this.handleBenchmarkUpdate(data); + }; + } + + handleBenchmarkUpdate(data) { + switch (data.type) { + case 'benchmark.started': + // Update UI to show "running" state + break; + case 'benchmark.progress': + // Update progress bar + break; + case 'benchmark.completed': + // Show final results + break; + } + } +} +``` + +## Event Flow + +### Complete Execution Flow + +``` +1. User runs benchmark: + make run test=Loop iterations=100 version=php84 + +2. BenchmarkCommand (CLI) + └─> ExecuteBenchmarkUseCase + └─> BenchmarkOrchestrator + └─> AsyncBenchmarkRunner.run() + +3. AsyncBenchmarkRunner dispatches events: + ┌─────────────────────────────────────────┐ + │ BenchmarkStarted │ + │ - benchmarkId: "Loop" │ + │ - phpVersion: "php84" │ + │ - totalIterations: 100 │ + └─────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────┐ + │ BenchmarkProgressSubscriber │ + │ - Receives event │ + │ - Publishes to Mercure: │ + │ POST http://mercure/.well-known/mercure│ + │ topic: benchmark/progress │ + │ data: {type: "benchmark.started", ...} │ + └─────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────┐ + │ Mercure Hub │ + │ - Receives update │ + │ - Broadcasts to all subscribers │ + └─────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────┐ + │ Browser (EventSource) │ + │ - Receives SSE message │ + │ - Stimulus controller updates DOM │ + │ - Progress bar shows 0% │ + └─────────────────────────────────────────┘ + +4. For each iteration (1 to 100): + - Execute benchmark + - Persist result + - Dispatch BenchmarkProgress + - Mercure broadcasts → Browser updates + +5. After all iterations: + - Dispatch BenchmarkCompleted + - Mercure broadcasts completion signal + - Browser displays "Completed" status + - Detailed statistics are available in the dashboard (from database) +``` + +## Configuration + +### Environment Variables + +**File**: `.env` + +```env +###> symfony/mercure-bundle ### +# Internal URL (backend → Mercure) +MERCURE_URL=http://mercure/.well-known/mercure + +# Public URL (browser → Mercure) +MERCURE_PUBLIC_URL=http://localhost:3000/.well-known/mercure + +# JWT secret for authentication +MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" +###< symfony/mercure-bundle ### +``` + +**Important**: +- `MERCURE_URL`: Used by Symfony to publish updates (Docker internal network) +- `MERCURE_PUBLIC_URL`: Used by browser to subscribe (accessible from host) +- `MERCURE_JWT_SECRET`: Must match Docker environment variable + +### Mercure Configuration + +**File**: `config/packages/mercure.yaml` + +```yaml +mercure: + hubs: + default: + url: '%env(MERCURE_URL)%' + public_url: '%env(MERCURE_PUBLIC_URL)%' + jwt: + secret: '%env(MERCURE_JWT_SECRET)%' + publish: '*' +``` + +### Docker Configuration + +**File**: `docker-compose.yml:149` + +```yaml +mercure: + image: dunglas/mercure + restart: unless-stopped + environment: + SERVER_NAME: ':80' + MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' + MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' + MERCURE_EXTRA_DIRECTIVES: | + cors_origins http://localhost:8000 http://127.0.0.1:8000 + anonymous + command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile + ports: + - "3000:80" + volumes: + - mercure_data:/data + - mercure_config:/config +``` + +**Key settings**: +- `SERVER_NAME: ':80'`: Disable HTTPS for development +- `anonymous`: Allow unauthenticated subscriptions +- `cors_origins`: Allow requests from Symfony app +- Port `3000`: Exposed for browser connections + +## Usage + +### Displaying Real-Time Progress + +**In Twig template**: + +```twig +{# Display real-time benchmark progress #} +
+ {{ component('BenchmarkProgress') }} +
+``` + +The component will: +1. Connect to Mercure on page load +2. Subscribe to `benchmark/progress` topic +3. Automatically update when benchmarks run +4. Display progress bars and results + +### Running Benchmarks + +**CLI**: + +```bash +# Run benchmark (will publish real-time updates) +make run test=Loop iterations=100 version=php84 +``` + +**Expected flow**: +1. Browser page shows "Waiting to start..." +2. Benchmark starts → Status changes to "Running" +3. Progress bar updates every iteration +4. Completion → Shows final metrics (average, p90, p95, p99) + +### Topics + +**Available topics**: + +| Topic | Description | Published By | +|-------|-------------|--------------| +| `benchmark/progress` | All progress updates (start, progress, complete) | `BenchmarkProgressSubscriber` | +| `benchmark/results` | Final results only | `BenchmarkProgressSubscriber` | + +### Manual Testing + +**Test Mercure connection**: + +```bash +# Subscribe to updates (in terminal) +curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" + +# Run benchmark (in another terminal) +make run test=Loop iterations=5 + +# You should see SSE events in first terminal +``` + +## Troubleshooting + +### Mercure not receiving updates + +**Symptom**: Benchmarks run but browser shows no updates + +**Checks**: +1. Verify Mercure is running: + ```bash + docker-compose ps mercure + # Should show "Up" + ``` + +2. Check Mercure logs: + ```bash + docker-compose logs -f mercure + ``` + +3. Test Mercure hub health: + ```bash + curl http://localhost:3000/.well-known/mercure + # Should return 200 OK (empty body is normal) + ``` + +4. Verify environment variables match: + ```bash + # In docker-compose.yml + MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' + + # In .env + MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" + + # Must be identical! + ``` + +### CORS errors in browser console + +**Symptom**: `Access-Control-Allow-Origin` errors + +**Solution**: Update `docker-compose.yml:157`: + +```yaml +MERCURE_EXTRA_DIRECTIVES: | + cors_origins http://localhost:8000 http://127.0.0.1:8000 + anonymous +``` + +Add any additional domains your app uses. + +### EventSource connection fails + +**Symptom**: `ERR_CONNECTION_REFUSED` in browser + +**Cause**: Mercure public URL incorrect + +**Solution**: Verify `.env`: +```env +MERCURE_PUBLIC_URL=http://localhost:3000/.well-known/mercure +# ^^^^^^^^^^^^ Must be accessible from browser +``` + +### Progress bar not updating + +**Symptom**: Events received but UI doesn't change + +**Debug**: + +1. Check browser console for errors +2. Verify Stimulus controller is loaded: + ```javascript + // In browser console + document.querySelector('[data-controller="mercure-progress"]') + ``` + +3. Check event data structure: + ```javascript + // In mercure-progress_controller.js + handleBenchmarkUpdate(data) { + console.log('Received:', data); + // Verify data.type, data.currentIteration, etc. + } + ``` + +### Events dispatched but not reaching Mercure + +**Symptom**: Backend logs show events dispatched, but Mercure receives nothing + +**Checks**: + +1. Verify `BenchmarkProgressSubscriber` is registered: + ```bash + docker-compose exec main php bin/console debug:event-dispatcher + # Should show BenchmarkProgressSubscriber + ``` + +2. Check subscriber is injecting HubInterface: + ```bash + docker-compose exec main php bin/console debug:autowiring HubInterface + ``` + +3. Test publishing manually: + ```php + // In a test controller + use Symfony\Component\Mercure\HubInterface; + use Symfony\Component\Mercure\Update; + + public function test(HubInterface $hub): Response + { + $update = new Update('test/topic', json_encode(['message' => 'Hello'])); + $hub->publish($update); + return new Response('Published'); + } + ``` + +### Benchmarks running in background not publishing + +**Symptom**: Benchmarks run asynchronously but events not dispatched + +**Cause**: Async processes may not share same event dispatcher + +**Solution**: Ensure `AsyncBenchmarkRunner` receives injected `EventDispatcherInterface`: + +```php +// src/Application/UseCase/AsyncBenchmarkRunner.php:23 +public function __construct( + private EventDispatcherInterface $eventDispatcher, // ← Must be injected +) {} +``` + +## Performance Considerations + +### Event Frequency + +**Current behavior**: `BenchmarkProgress` dispatched after **every iteration** + +For 1000 iterations: +- 1000 SSE messages sent +- May overwhelm browser/network + +**Optimization**: Throttle progress events + +```php +// AsyncBenchmarkRunner.php +if ($completedIterations % 10 === 0) { // Every 10 iterations + $this->eventDispatcher->dispatch(new BenchmarkProgress(...)); +} +``` + +### Mercure Connection Limit + +Mercure has a default connection limit. For production: + +```yaml +# docker-compose.yml (production) +MERCURE_SUBSCRIBER_HEARTBEAT_INTERVAL: '15s' +MERCURE_TRANSPORT_URL: 'bolt://mercure.db' +``` + +### Browser Memory + +Long-running EventSource connections accumulate messages. Consider: + +```javascript +// Auto-close after benchmark completes +handleBenchmarkUpdate(data) { + if (data.type === 'benchmark.completed') { + setTimeout(() => { + this.eventSource.close(); + }, 5000); // Close after 5 seconds + } +} +``` + +## Security + +### Production Configuration + +**DO NOT use in production**: +```yaml +MERCURE_EXTRA_DIRECTIVES: | + anonymous # ← Allows anyone to subscribe +``` + +**Production setup**: + +1. Remove `anonymous` directive +2. Generate subscriber JWT tokens +3. Pass token to frontend: + +```php +// In controller +use Symfony\Component\Mercure\Authorization; + +public function dashboard(Authorization $authorization): Response +{ + $authorization->setCookie( + $request, + ['benchmark/progress'] // Topics user can access + ); +} +``` + +### JWT Secret Rotation + +Change `MERCURE_JWT_SECRET` periodically: + +```bash +# Generate new secret +openssl rand -base64 32 + +# Update .env and docker-compose.yml +# Restart containers +docker-compose restart mercure main +``` + +--- + +## References + +- [Mercure Protocol Specification](https://mercure.rocks/spec) +- [Symfony Mercure Bundle](https://symfony.com/bundles/MercureBundle/current/index.html) +- [Symfony UX Live Components](https://symfony.com/bundles/ux-live-component/current/index.html) +- [Server-Sent Events (SSE) API](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) +- [Stimulus Controllers](https://stimulus.hotwired.dev/) + +--- + +**Last updated**: 2025-10-22 +**Maintained by**: Project contributors diff --git a/fixtures/benchmarks/abs-with-abs.yaml b/fixtures/benchmarks/abs-with-abs.yaml new file mode 100644 index 0000000..1bd43ad --- /dev/null +++ b/fixtures/benchmarks/abs-with-abs.yaml @@ -0,0 +1,22 @@ +slug: abs-with-abs +name: 'Abs With Abs' +category: Numeric +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = -50000; 50000 > $i; ++$i) { + $result = abs($i); + } diff --git a/fixtures/benchmarks/abs-with-ternary.yaml b/fixtures/benchmarks/abs-with-ternary.yaml new file mode 100644 index 0000000..6cfd0e8 --- /dev/null +++ b/fixtures/benchmarks/abs-with-ternary.yaml @@ -0,0 +1,22 @@ +slug: abs-with-ternary +name: 'Abs With Ternary' +category: Numeric +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = -50000; 50000 > $i; ++$i) { + $result = 0 > $i ? -$i : $i; + } diff --git a/fixtures/benchmarks/array-fill-benchmark.yaml b/fixtures/benchmarks/array-fill-benchmark.yaml new file mode 100644 index 0000000..8f8073b --- /dev/null +++ b/fixtures/benchmarks/array-fill-benchmark.yaml @@ -0,0 +1,26 @@ +slug: array-fill +name: 'Array Fill' +category: 'Array Operations' +description: 'Fill array using array_fill() function' +icon: 📦 +tags: + - array + - fill + - initialization +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; $i < 10000; $i++) { + $array = array_fill(0, 100, 'value'); + } diff --git a/fixtures/benchmarks/array-first-index.yaml b/fixtures/benchmarks/array-first-index.yaml new file mode 100644 index 0000000..ab930f9 --- /dev/null +++ b/fixtures/benchmarks/array-first-index.yaml @@ -0,0 +1,27 @@ +slug: first-item-in-array +name: 'Array First with [0]' +category: 'Array Operations' +description: 'Get first element of array using direct index access [0]' +icon: 🔢 +tags: + - array + - first + - index +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $array = range(1, 1000); + for ($i = 0; $i < 10000; $i++) { + $first = $array[0]; + } diff --git a/fixtures/benchmarks/array-first-reset.yaml b/fixtures/benchmarks/array-first-reset.yaml new file mode 100644 index 0000000..b0530af --- /dev/null +++ b/fixtures/benchmarks/array-first-reset.yaml @@ -0,0 +1,27 @@ +slug: array-first-reset +name: 'Array First with reset()' +category: 'Array Operations' +description: 'Get first element of array using reset() function' +icon: 🔢 +tags: + - array + - first + - reset +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $array = range(1, 1000); + for ($i = 0; $i < 10000; $i++) { + $first = reset($array); + } diff --git a/fixtures/benchmarks/array-key-first.yaml b/fixtures/benchmarks/array-key-first.yaml new file mode 100644 index 0000000..c13429f --- /dev/null +++ b/fixtures/benchmarks/array-key-first.yaml @@ -0,0 +1,24 @@ +slug: array-key-first +name: 'Array Key First (PHP 7.3+)' +category: 'Array Operations' +description: 'Get first key of array using array_key_first() function (PHP 7.3+)' +icon: 🔑 +tags: + - array + - first + - key + - php73 +phpVersions: + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $array = range(1, 1000); + for ($i = 0; $i < 10000; $i++) { + $firstKey = array_key_first($array); + } diff --git a/fixtures/benchmarks/assignment-with-null-coalescing.yaml b/fixtures/benchmarks/assignment-with-null-coalescing.yaml new file mode 100644 index 0000000..b6eb26f --- /dev/null +++ b/fixtures/benchmarks/assignment-with-null-coalescing.yaml @@ -0,0 +1,18 @@ +slug: assignment-with-null-coalescing +name: 'Assignment With Null Coalescing' +category: NullCoalescing +description: '' +tags: { } +phpVersions: + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 100000 > $i; ++$i) { + $result = null; + $result ??= 'default'; + } diff --git a/fixtures/benchmarks/build-with-array-implode.yaml b/fixtures/benchmarks/build-with-array-implode.yaml new file mode 100644 index 0000000..27f860d --- /dev/null +++ b/fixtures/benchmarks/build-with-array-implode.yaml @@ -0,0 +1,23 @@ +slug: build-with-array-implode +name: 'Build With Array Implode' +category: Buffering +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $lines = []; + for ($i = 0; 1000 > $i; ++$i) { + $lines[] = 'Line ' . $i; + } diff --git a/fixtures/benchmarks/build-with-concatenation.yaml b/fixtures/benchmarks/build-with-concatenation.yaml new file mode 100644 index 0000000..405d878 --- /dev/null +++ b/fixtures/benchmarks/build-with-concatenation.yaml @@ -0,0 +1,23 @@ +slug: build-with-concatenation +name: 'Build With Concatenation' +category: Buffering +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $result = ''; + for ($i = 0; 1000 > $i; ++$i) { + $result .= 'Line ' . $i . "\n"; + } diff --git a/fixtures/benchmarks/build-with-output-buffer.yaml b/fixtures/benchmarks/build-with-output-buffer.yaml new file mode 100644 index 0000000..1ff0e4b --- /dev/null +++ b/fixtures/benchmarks/build-with-output-buffer.yaml @@ -0,0 +1,25 @@ +slug: build-with-output-buffer +name: 'Build With Output Buffer' +category: Buffering +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + ob_start(); + for ($i = 0; 1000 > $i; ++$i) { + echo 'Line ' . $i . "\n"; + } + + ob_get_clean(); diff --git a/fixtures/benchmarks/call-with-arrow-function.yaml b/fixtures/benchmarks/call-with-arrow-function.yaml new file mode 100644 index 0000000..7367a6c --- /dev/null +++ b/fixtures/benchmarks/call-with-arrow-function.yaml @@ -0,0 +1,17 @@ +slug: call-with-arrow-function +name: 'Call With Arrow Function' +category: Callbacks +description: '' +tags: { } +phpVersions: + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 100000 > $i; ++$i) { + $result = (fn ($x): int => $x * 2)($i); + } diff --git a/fixtures/benchmarks/call-with-call-user-func.yaml b/fixtures/benchmarks/call-with-call-user-func.yaml new file mode 100644 index 0000000..f4211b4 --- /dev/null +++ b/fixtures/benchmarks/call-with-call-user-func.yaml @@ -0,0 +1,18 @@ +slug: call-with-call-user-func +name: 'Call With Call User Func' +category: Callbacks +description: '' +tags: { } +phpVersions: + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $func = (fn ($x): int|float => $x * 2); + + for ($i = 0; 100000 > $i; ++$i) { + $result = call_user_func($func, $i); + } diff --git a/fixtures/benchmarks/call-with-closure.yaml b/fixtures/benchmarks/call-with-closure.yaml new file mode 100644 index 0000000..c94bf41 --- /dev/null +++ b/fixtures/benchmarks/call-with-closure.yaml @@ -0,0 +1,17 @@ +slug: call-with-closure +name: 'Call With Closure' +category: Callbacks +description: '' +tags: { } +phpVersions: + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 100000 > $i; ++$i) { + $result = (fn ($x): int => $x * 2)($i); + } diff --git a/fixtures/benchmarks/call-with-direct-invocation.yaml b/fixtures/benchmarks/call-with-direct-invocation.yaml new file mode 100644 index 0000000..07daa27 --- /dev/null +++ b/fixtures/benchmarks/call-with-direct-invocation.yaml @@ -0,0 +1,18 @@ +slug: call-with-direct-invocation +name: 'Call With Direct Invocation' +category: Callbacks +description: '' +tags: { } +phpVersions: + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $func = (fn ($x): int|float => $x * 2); + + for ($i = 0; 100000 > $i; ++$i) { + $result = $func($i); + } diff --git a/fixtures/benchmarks/chaining-functions.yaml b/fixtures/benchmarks/chaining-functions.yaml new file mode 100644 index 0000000..e433d87 --- /dev/null +++ b/fixtures/benchmarks/chaining-functions.yaml @@ -0,0 +1,45 @@ +slug: chaining-functions +name: 'Function Chaining' +category: 'Function Operations' +description: 'Chain multiple function calls together' +icon: ⛓️ +tags: + - function + - chaining + - fluent +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + class Calculator { + private $value = 0; + + public function add($n) { + $this->value += $n; + return $this; + } + + public function multiply($n) { + $this->value *= $n; + return $this; + } + + public function getValue() { + return $this->value; + } + } + + for ($i = 0; $i < 10000; $i++) { + $calc = new Calculator(); + $result = $calc->add(5)->multiply(2)->add(3)->getValue(); + } diff --git a/fixtures/benchmarks/check-with-get-debug-type.yaml b/fixtures/benchmarks/check-with-get-debug-type.yaml new file mode 100644 index 0000000..edafa7b --- /dev/null +++ b/fixtures/benchmarks/check-with-get-debug-type.yaml @@ -0,0 +1,18 @@ +slug: check-with-get-debug-type +name: 'Check With Get Debug Type' +category: TypeChecking +description: '' +tags: { } +phpVersions: + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = [1, 2, 3, 4, 5]; + + for ($i = 0; 100000 > $i; ++$i) { + $result = 'array' === get_debug_type($data); + } diff --git a/fixtures/benchmarks/check-with-gettype.yaml b/fixtures/benchmarks/check-with-gettype.yaml new file mode 100644 index 0000000..f86af6c --- /dev/null +++ b/fixtures/benchmarks/check-with-gettype.yaml @@ -0,0 +1,24 @@ +slug: check-with-gettype +name: 'Check With Gettype' +category: TypeChecking +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = [1, 2, 3, 4, 5]; + + for ($i = 0; 100000 > $i; ++$i) { + $result = 'array' === gettype($data); + } diff --git a/fixtures/benchmarks/check-with-is-array.yaml b/fixtures/benchmarks/check-with-is-array.yaml new file mode 100644 index 0000000..3cb71cf --- /dev/null +++ b/fixtures/benchmarks/check-with-is-array.yaml @@ -0,0 +1,24 @@ +slug: check-with-is-array +name: 'Check With Is Array' +category: TypeChecking +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = [1, 2, 3, 4, 5]; + + for ($i = 0; 100000 > $i; ++$i) { + $result = is_array($data); + } diff --git a/fixtures/benchmarks/check-with-isset-ternary.yaml b/fixtures/benchmarks/check-with-isset-ternary.yaml new file mode 100644 index 0000000..08ba4ba --- /dev/null +++ b/fixtures/benchmarks/check-with-isset-ternary.yaml @@ -0,0 +1,24 @@ +slug: check-with-isset-ternary +name: 'Check With Isset Ternary' +category: NullCoalescing +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = ['key1' => 'value1', 'key2' => null, 'key3' => 'value3']; + + for ($i = 0; 100000 > $i; ++$i) { + $result = $data['key2'] ?? 'default'; + } diff --git a/fixtures/benchmarks/check-with-isset.yaml b/fixtures/benchmarks/check-with-isset.yaml new file mode 100644 index 0000000..8b9f912 --- /dev/null +++ b/fixtures/benchmarks/check-with-isset.yaml @@ -0,0 +1,25 @@ +slug: check-with-isset +name: 'Check With Isset' +category: ObjectOperations +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $obj = new stdClass(); + $obj->value = 123; + + for ($i = 0; 100000 > $i; ++$i) { + $result = isset($obj->value); + } diff --git a/fixtures/benchmarks/check-with-null-coalescing.yaml b/fixtures/benchmarks/check-with-null-coalescing.yaml new file mode 100644 index 0000000..37a3f85 --- /dev/null +++ b/fixtures/benchmarks/check-with-null-coalescing.yaml @@ -0,0 +1,23 @@ +slug: check-with-null-coalescing +name: 'Check With Null Coalescing' +category: NullCoalescing +description: '' +tags: { } +phpVersions: + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = ['key1' => 'value1', 'key2' => null, 'key3' => 'value3']; + + for ($i = 0; 100000 > $i; ++$i) { + $result = $data['key2'] ?? 'default'; + } diff --git a/fixtures/benchmarks/check-with-property-exists.yaml b/fixtures/benchmarks/check-with-property-exists.yaml new file mode 100644 index 0000000..6b799bf --- /dev/null +++ b/fixtures/benchmarks/check-with-property-exists.yaml @@ -0,0 +1,25 @@ +slug: check-with-property-exists +name: 'Check With Property Exists' +category: ObjectOperations +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $obj = new stdClass(); + $obj->value = 123; + + for ($i = 0; 100000 > $i; ++$i) { + $result = property_exists($obj, 'value'); + } diff --git a/fixtures/benchmarks/clone-with-clone.yaml b/fixtures/benchmarks/clone-with-clone.yaml new file mode 100644 index 0000000..51dc81b --- /dev/null +++ b/fixtures/benchmarks/clone-with-clone.yaml @@ -0,0 +1,26 @@ +slug: clone-with-clone +name: 'Clone With Clone' +category: ObjectCloning +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $original = new stdClass(); + $original->value = 123; + $original->name = 'test'; + + for ($i = 0; 100000 > $i; ++$i) { + $copy = clone $original; + } diff --git a/fixtures/benchmarks/clone-with-new-instance.yaml b/fixtures/benchmarks/clone-with-new-instance.yaml new file mode 100644 index 0000000..49c31c8 --- /dev/null +++ b/fixtures/benchmarks/clone-with-new-instance.yaml @@ -0,0 +1,24 @@ +slug: clone-with-new-instance +name: 'Clone With New Instance' +category: ObjectCloning +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 100000 > $i; ++$i) { + $copy = new stdClass(); + $copy->value = 123; + $copy->name = 'test'; + } diff --git a/fixtures/benchmarks/clone-with-serialize.yaml b/fixtures/benchmarks/clone-with-serialize.yaml new file mode 100644 index 0000000..64e4999 --- /dev/null +++ b/fixtures/benchmarks/clone-with-serialize.yaml @@ -0,0 +1,26 @@ +slug: clone-with-serialize +name: 'Clone With Serialize' +category: ObjectCloning +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $original = new stdClass(); + $original->value = 123; + $original->name = 'test'; + + for ($i = 0; 100000 > $i; ++$i) { + $copy = unserialize(serialize($original)); + } diff --git a/fixtures/benchmarks/column-with-array-column.yaml b/fixtures/benchmarks/column-with-array-column.yaml new file mode 100644 index 0000000..5dc1308 --- /dev/null +++ b/fixtures/benchmarks/column-with-array-column.yaml @@ -0,0 +1,23 @@ +slug: column-with-array-column +name: 'Column With Array Column' +category: AdvancedArrays +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = []; + for ($i = 0; 1000 > $i; ++$i) { + $data[] = ['id' => $i, 'name' => 'User' . $i, 'email' => 'user' . $i . '@test.com']; + } diff --git a/fixtures/benchmarks/column-with-array-map.yaml b/fixtures/benchmarks/column-with-array-map.yaml new file mode 100644 index 0000000..fd68ef2 --- /dev/null +++ b/fixtures/benchmarks/column-with-array-map.yaml @@ -0,0 +1,20 @@ +slug: column-with-array-map +name: 'Column With Array Map' +category: AdvancedArrays +description: '' +tags: { } +phpVersions: + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = []; + for ($i = 0; 1000 > $i; ++$i) { + $data[] = ['id' => $i, 'name' => 'User' . $i, 'email' => 'user' . $i . '@test.com']; + } + + array_map(fn (array $item): string => $item['name'], $data); diff --git a/fixtures/benchmarks/compare-with-match.yaml b/fixtures/benchmarks/compare-with-match.yaml new file mode 100644 index 0000000..c7e41e6 --- /dev/null +++ b/fixtures/benchmarks/compare-with-match.yaml @@ -0,0 +1,23 @@ +slug: compare-with-match +name: 'Compare With Match' +category: MatchExpression +description: '' +tags: { } +phpVersions: + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 100000 > $i; ++$i) { + $value = $i % 5; + $result = match ($value) { + 0 => 'zero', + 1 => 'one', + 2 => 'two', + 3 => 'three', + default => 'other', + }; + } diff --git a/fixtures/benchmarks/compare-with-switch.yaml b/fixtures/benchmarks/compare-with-switch.yaml new file mode 100644 index 0000000..ec738e1 --- /dev/null +++ b/fixtures/benchmarks/compare-with-switch.yaml @@ -0,0 +1,23 @@ +slug: compare-with-switch +name: 'Compare With Switch' +category: MatchExpression +description: '' +tags: { } +phpVersions: + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 100000 > $i; ++$i) { + $value = $i % 5; + $result = match ($value) { + 0 => 'zero', + 1 => 'one', + 2 => 'two', + 3 => 'three', + default => 'other', + }; + } diff --git a/fixtures/benchmarks/concatenation-with-dot.yaml b/fixtures/benchmarks/concatenation-with-dot.yaml new file mode 100644 index 0000000..104748c --- /dev/null +++ b/fixtures/benchmarks/concatenation-with-dot.yaml @@ -0,0 +1,28 @@ +slug: concatenation-with-dot +name: 'Dot Operator Concatenation' +category: 'String Operations' +description: 'String concatenation using the dot operator in a loop' +icon: 🔗 +tags: + - string + - concatenation + - dot-operator + - performance +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $result = ''; + for ($i = 0; 10000 > $i; ++$i) { + $result = 'Hello World ' . $i . ' test benchmark'; + } diff --git a/fixtures/benchmarks/concatenation-with-implode.yaml b/fixtures/benchmarks/concatenation-with-implode.yaml new file mode 100644 index 0000000..330864b --- /dev/null +++ b/fixtures/benchmarks/concatenation-with-implode.yaml @@ -0,0 +1,23 @@ +slug: concatenation-with-implode +name: 'Concatenation With Implode' +category: StringConcatenation +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $result = ''; + for ($i = 0; 10000 > $i; ++$i) { + $result = implode(' ', ['Hello', 'World', $i, 'test', 'benchmark']); + } diff --git a/fixtures/benchmarks/concatenation-with-interpolation.yaml b/fixtures/benchmarks/concatenation-with-interpolation.yaml new file mode 100644 index 0000000..e1f0645 --- /dev/null +++ b/fixtures/benchmarks/concatenation-with-interpolation.yaml @@ -0,0 +1,23 @@ +slug: concatenation-with-interpolation +name: 'Concatenation With Interpolation' +category: StringConcatenation +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $result = ''; + for ($i = 0; 10000 > $i; ++$i) { + $result = sprintf('Hello World %d test benchmark', $i); + } diff --git a/fixtures/benchmarks/concatenation-with-sprintf.yaml b/fixtures/benchmarks/concatenation-with-sprintf.yaml new file mode 100644 index 0000000..9754765 --- /dev/null +++ b/fixtures/benchmarks/concatenation-with-sprintf.yaml @@ -0,0 +1,23 @@ +slug: concatenation-with-sprintf +name: 'Concatenation With Sprintf' +category: StringConcatenation +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $result = ''; + for ($i = 0; 10000 > $i; ++$i) { + $result = sprintf('%s %s %d %s %s', 'Hello', 'World', $i, 'test', 'benchmark'); + } diff --git a/fixtures/benchmarks/convert-string-with-cast.yaml b/fixtures/benchmarks/convert-string-with-cast.yaml new file mode 100644 index 0000000..5bef84e --- /dev/null +++ b/fixtures/benchmarks/convert-string-with-cast.yaml @@ -0,0 +1,22 @@ +slug: convert-string-with-cast +name: 'Convert String With Cast' +category: TypeConversion +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 100000 > $i; ++$i) { + $result = (string) 123; + } diff --git a/fixtures/benchmarks/convert-string-with-strval.yaml b/fixtures/benchmarks/convert-string-with-strval.yaml new file mode 100644 index 0000000..f521d8a --- /dev/null +++ b/fixtures/benchmarks/convert-string-with-strval.yaml @@ -0,0 +1,22 @@ +slug: convert-string-with-strval +name: 'Convert String With Strval' +category: TypeConversion +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 100000 > $i; ++$i) { + $result = (string) 123; + } diff --git a/fixtures/benchmarks/convert-with-cast.yaml b/fixtures/benchmarks/convert-with-cast.yaml new file mode 100644 index 0000000..bee7476 --- /dev/null +++ b/fixtures/benchmarks/convert-with-cast.yaml @@ -0,0 +1,22 @@ +slug: convert-with-cast +name: 'Convert With Cast' +category: TypeConversion +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 100000 > $i; ++$i) { + $result = (int) '123'; + } diff --git a/fixtures/benchmarks/convert-with-intval.yaml b/fixtures/benchmarks/convert-with-intval.yaml new file mode 100644 index 0000000..d559a33 --- /dev/null +++ b/fixtures/benchmarks/convert-with-intval.yaml @@ -0,0 +1,22 @@ +slug: convert-with-intval +name: 'Convert With Intval' +category: TypeConversion +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 100000 > $i; ++$i) { + $result = (int) '123'; + } diff --git a/fixtures/benchmarks/divide-with-cast.yaml b/fixtures/benchmarks/divide-with-cast.yaml new file mode 100644 index 0000000..23d8f86 --- /dev/null +++ b/fixtures/benchmarks/divide-with-cast.yaml @@ -0,0 +1,22 @@ +slug: divide-with-cast +name: 'Divide With Cast' +category: Numeric +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 1; 100000 > $i; ++$i) { + $result = (int) (100 / ($i % 50 + 1)); + } diff --git a/fixtures/benchmarks/divide-with-intdiv.yaml b/fixtures/benchmarks/divide-with-intdiv.yaml new file mode 100644 index 0000000..b421aad --- /dev/null +++ b/fixtures/benchmarks/divide-with-intdiv.yaml @@ -0,0 +1,21 @@ +slug: divide-with-intdiv +name: 'Divide With Intdiv' +category: Numeric +description: '' +tags: { } +phpVersions: + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 1; 100000 > $i; ++$i) { + $result = intdiv(100, $i % 50 + 1); + } diff --git a/fixtures/benchmarks/encode-with-json-encode.yaml b/fixtures/benchmarks/encode-with-json-encode.yaml new file mode 100644 index 0000000..7f79712 --- /dev/null +++ b/fixtures/benchmarks/encode-with-json-encode.yaml @@ -0,0 +1,29 @@ +slug: encode-with-json-encode +name: 'Encode With Json Encode' +category: JsonOperations +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'age' => 30, + 'items' => range(1, 100), + ]; + + for ($i = 0; 10000 > $i; ++$i) { + $result = json_encode($data); + } diff --git a/fixtures/benchmarks/encode-with-serialize.yaml b/fixtures/benchmarks/encode-with-serialize.yaml new file mode 100644 index 0000000..1d29189 --- /dev/null +++ b/fixtures/benchmarks/encode-with-serialize.yaml @@ -0,0 +1,29 @@ +slug: encode-with-serialize +name: 'Encode With Serialize' +category: JsonOperations +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'age' => 30, + 'items' => range(1, 100), + ]; + + for ($i = 0; 10000 > $i; ++$i) { + $result = serialize($data); + } diff --git a/fixtures/benchmarks/extract-with-array-destructuring.yaml b/fixtures/benchmarks/extract-with-array-destructuring.yaml new file mode 100644 index 0000000..28dd4e4 --- /dev/null +++ b/fixtures/benchmarks/extract-with-array-destructuring.yaml @@ -0,0 +1,22 @@ +slug: extract-with-array-destructuring +name: 'Extract With Array Destructuring' +category: ValueExtraction +description: '' +tags: { } +phpVersions: + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = [1, 2, 3]; + + for ($i = 0; 100000 > $i; ++$i) { + [$a, $b, $c] = $data; + } diff --git a/fixtures/benchmarks/extract-with-list.yaml b/fixtures/benchmarks/extract-with-list.yaml new file mode 100644 index 0000000..fef2079 --- /dev/null +++ b/fixtures/benchmarks/extract-with-list.yaml @@ -0,0 +1,24 @@ +slug: extract-with-list +name: 'Extract With List' +category: ValueExtraction +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = [1, 2, 3]; + + for ($i = 0; 100000 > $i; ++$i) { + [$a, $b, $c] = $data; + } diff --git a/fixtures/benchmarks/extract-with-manual-assignment.yaml b/fixtures/benchmarks/extract-with-manual-assignment.yaml new file mode 100644 index 0000000..a56122c --- /dev/null +++ b/fixtures/benchmarks/extract-with-manual-assignment.yaml @@ -0,0 +1,26 @@ +slug: extract-with-manual-assignment +name: 'Extract With Manual Assignment' +category: ValueExtraction +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = [1, 2, 3]; + + for ($i = 0; 100000 > $i; ++$i) { + $a = $data[0]; + $b = $data[1]; + $c = $data[2]; + } diff --git a/fixtures/benchmarks/filter-with-array-filter.yaml b/fixtures/benchmarks/filter-with-array-filter.yaml new file mode 100644 index 0000000..183c902 --- /dev/null +++ b/fixtures/benchmarks/filter-with-array-filter.yaml @@ -0,0 +1,17 @@ +slug: filter-with-array-filter +name: 'Filter With Array Filter' +category: AdvancedArrays +description: '' +tags: { } +phpVersions: + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = range(1, 10000); + + array_filter($data, fn ($item): bool => 0 === $item % 2); diff --git a/fixtures/benchmarks/filter-with-foreach.yaml b/fixtures/benchmarks/filter-with-foreach.yaml new file mode 100644 index 0000000..2385ff5 --- /dev/null +++ b/fixtures/benchmarks/filter-with-foreach.yaml @@ -0,0 +1,27 @@ +slug: filter-with-foreach +name: 'Filter With Foreach' +category: AdvancedArrays +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = range(1, 10000); + $result = []; + + foreach ($data as $item) { + if (0 === $item % 2) { + $result[] = $item; + } + } diff --git a/fixtures/benchmarks/foreach-by-reference.yaml b/fixtures/benchmarks/foreach-by-reference.yaml new file mode 100644 index 0000000..af95a94 --- /dev/null +++ b/fixtures/benchmarks/foreach-by-reference.yaml @@ -0,0 +1,24 @@ +slug: foreach-by-reference +name: 'Foreach By Reference' +category: References +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = range(1, 10000); + + foreach ($data as &$value) { + $value *= 2; + } diff --git a/fixtures/benchmarks/foreach-by-value.yaml b/fixtures/benchmarks/foreach-by-value.yaml new file mode 100644 index 0000000..f8e35eb --- /dev/null +++ b/fixtures/benchmarks/foreach-by-value.yaml @@ -0,0 +1,24 @@ +slug: foreach-by-value +name: 'Foreach By Value' +category: References +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = range(1, 10000); + + foreach ($data as $value) { + $value *= 2; + } diff --git a/fixtures/benchmarks/handle-with-condition.yaml b/fixtures/benchmarks/handle-with-condition.yaml new file mode 100644 index 0000000..fcba0ef --- /dev/null +++ b/fixtures/benchmarks/handle-with-condition.yaml @@ -0,0 +1,28 @@ +slug: handle-with-condition +name: 'Conditional Error Prevention' +category: 'Error Handling' +description: 'Prevents division by zero using conditional checks instead of try-catch' +icon: ⚠️ +tags: + - error-handling + - conditional + - division + - validation +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 10000 > $i; ++$i) { + $divisor = 0 === $i % 10 ? 1 : $i; + $result = 0 !== $divisor ? 100 / $divisor : 0; + } diff --git a/fixtures/benchmarks/handle-with-suppression.yaml b/fixtures/benchmarks/handle-with-suppression.yaml new file mode 100644 index 0000000..7184283 --- /dev/null +++ b/fixtures/benchmarks/handle-with-suppression.yaml @@ -0,0 +1,22 @@ +slug: handle-with-suppression +name: 'Handle With Suppression' +category: ErrorHandling +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 10000 > $i; ++$i) { + $result = @(100 / (0 === $i % 10 ? 1 : $i)); + } diff --git a/fixtures/benchmarks/handle-with-try-catch.yaml b/fixtures/benchmarks/handle-with-try-catch.yaml new file mode 100644 index 0000000..2a1b0ed --- /dev/null +++ b/fixtures/benchmarks/handle-with-try-catch.yaml @@ -0,0 +1,26 @@ +slug: handle-with-try-catch +name: 'Handle With Try Catch' +category: ErrorHandling +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 10000 > $i; ++$i) { + try { + $result = 100 / (0 === $i % 10 ? 1 : $i); + } catch (Throwable) { + $result = 0; + } + } diff --git a/fixtures/benchmarks/hash-with-crc32.yaml b/fixtures/benchmarks/hash-with-crc32.yaml new file mode 100644 index 0000000..d2f847c --- /dev/null +++ b/fixtures/benchmarks/hash-with-crc32.yaml @@ -0,0 +1,22 @@ +slug: hash-with-crc32 +name: 'Hash With Crc32' +category: Hashing +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 50000 > $i; ++$i) { + $result = crc32('test string ' . $i); + } diff --git a/fixtures/benchmarks/hash-with-md5.yaml b/fixtures/benchmarks/hash-with-md5.yaml new file mode 100644 index 0000000..a36b763 --- /dev/null +++ b/fixtures/benchmarks/hash-with-md5.yaml @@ -0,0 +1,22 @@ +slug: hash-with-md5 +name: 'Hash With Md5' +category: Hashing +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 50000 > $i; ++$i) { + $result = md5('test string ' . $i); + } diff --git a/fixtures/benchmarks/hash-with-sha1.yaml b/fixtures/benchmarks/hash-with-sha1.yaml new file mode 100644 index 0000000..6aeae51 --- /dev/null +++ b/fixtures/benchmarks/hash-with-sha1.yaml @@ -0,0 +1,22 @@ +slug: hash-with-sha1 +name: 'Hash With Sha1' +category: Hashing +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 50000 > $i; ++$i) { + $result = sha1('test string ' . $i); + } diff --git a/fixtures/benchmarks/hash-with-sha256.yaml b/fixtures/benchmarks/hash-with-sha256.yaml new file mode 100644 index 0000000..9fa3979 --- /dev/null +++ b/fixtures/benchmarks/hash-with-sha256.yaml @@ -0,0 +1,22 @@ +slug: hash-with-sha256 +name: 'Hash With Sha256' +category: Hashing +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 50000 > $i; ++$i) { + $result = hash('sha256', 'test string ' . $i); + } diff --git a/fixtures/benchmarks/iterate-with-for.yaml b/fixtures/benchmarks/iterate-with-for.yaml new file mode 100644 index 0000000..f9496c7 --- /dev/null +++ b/fixtures/benchmarks/iterate-with-for.yaml @@ -0,0 +1,26 @@ +slug: iterate-with-for +name: 'Iterate With For' +category: Iteration +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = range(1, 10000); + $sum = 0; + $count = count($data); + + for ($i = 0; $i < $count; ++$i) { + $sum += $data[$i]; + } diff --git a/fixtures/benchmarks/iterate-with-foreach.yaml b/fixtures/benchmarks/iterate-with-foreach.yaml new file mode 100644 index 0000000..92d3bdd --- /dev/null +++ b/fixtures/benchmarks/iterate-with-foreach.yaml @@ -0,0 +1,25 @@ +slug: iterate-with-foreach +name: 'Iterate With Foreach' +category: Iteration +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = range(1, 10000); + $sum = 0; + + foreach ($data as $value) { + $sum += $value; + } diff --git a/fixtures/benchmarks/iterate-with-generator.yaml b/fixtures/benchmarks/iterate-with-generator.yaml new file mode 100644 index 0000000..4d4a1ec --- /dev/null +++ b/fixtures/benchmarks/iterate-with-generator.yaml @@ -0,0 +1,30 @@ +slug: iterate-with-generator +name: 'Iterate With Generator' +category: Iteration +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + function generateRange() + { + for ($i = 1; 10000 >= $i; ++$i) { + yield $i; + } + } + + $sum = 0; + foreach (generateRange() as $value) { + $sum += $value; + } diff --git a/fixtures/benchmarks/length-with-mb-strlen.yaml b/fixtures/benchmarks/length-with-mb-strlen.yaml new file mode 100644 index 0000000..07e0200 --- /dev/null +++ b/fixtures/benchmarks/length-with-mb-strlen.yaml @@ -0,0 +1,24 @@ +slug: length-with-mb-strlen +name: 'Length With Mb Strlen' +category: AdvancedStrings +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $text = 'Hello World, this is a test string for benchmarking'; + + for ($i = 0; 100000 > $i; ++$i) { + $result = mb_strlen($text); + } diff --git a/fixtures/benchmarks/length-with-strlen.yaml b/fixtures/benchmarks/length-with-strlen.yaml new file mode 100644 index 0000000..0d4465d --- /dev/null +++ b/fixtures/benchmarks/length-with-strlen.yaml @@ -0,0 +1,24 @@ +slug: length-with-strlen +name: 'Length With Strlen' +category: AdvancedStrings +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $text = 'Hello World, this is a test string for benchmarking'; + + for ($i = 0; 100000 > $i; ++$i) { + $result = mb_strlen($text); + } diff --git a/fixtures/benchmarks/lower-with-mb-strtolower.yaml b/fixtures/benchmarks/lower-with-mb-strtolower.yaml new file mode 100644 index 0000000..4614f5e --- /dev/null +++ b/fixtures/benchmarks/lower-with-mb-strtolower.yaml @@ -0,0 +1,24 @@ +slug: lower-with-mb-strtolower +name: 'Lower With Mb Strtolower' +category: AdvancedStrings +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $text = 'HELLO WORLD THIS IS A TEST STRING'; + + for ($i = 0; 100000 > $i; ++$i) { + $result = mb_strtolower($text); + } diff --git a/fixtures/benchmarks/lower-with-strtolower.yaml b/fixtures/benchmarks/lower-with-strtolower.yaml new file mode 100644 index 0000000..9079bc2 --- /dev/null +++ b/fixtures/benchmarks/lower-with-strtolower.yaml @@ -0,0 +1,24 @@ +slug: lower-with-strtolower +name: 'Lower With Strtolower' +category: AdvancedStrings +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $text = 'HELLO WORLD THIS IS A TEST STRING'; + + for ($i = 0; 100000 > $i; ++$i) { + $result = mb_strtolower($text); + } diff --git a/fixtures/benchmarks/map-with-array-map.yaml b/fixtures/benchmarks/map-with-array-map.yaml new file mode 100644 index 0000000..9cc6c28 --- /dev/null +++ b/fixtures/benchmarks/map-with-array-map.yaml @@ -0,0 +1,17 @@ +slug: map-with-array-map +name: 'Map With Array Map' +category: ArrayMap +description: '' +tags: { } +phpVersions: + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = range(1, 10000); + + array_map(fn ($item): int => $item * 2, $data); diff --git a/fixtures/benchmarks/map-with-foreach.yaml b/fixtures/benchmarks/map-with-foreach.yaml new file mode 100644 index 0000000..cbe9211 --- /dev/null +++ b/fixtures/benchmarks/map-with-foreach.yaml @@ -0,0 +1,25 @@ +slug: map-with-foreach +name: 'Map With Foreach' +category: ArrayMap +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = range(1, 10000); + $result = []; + + foreach ($data as $item) { + $result[] = $item * 2; + } diff --git a/fixtures/benchmarks/match-all-with-preg-match-all.yaml b/fixtures/benchmarks/match-all-with-preg-match-all.yaml new file mode 100644 index 0000000..76e88ba --- /dev/null +++ b/fixtures/benchmarks/match-all-with-preg-match-all.yaml @@ -0,0 +1,24 @@ +slug: match-all-with-preg-match-all +name: 'Match All With Preg Match All' +category: Regex +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $text = 'Numbers: 123, 456, 789, 012, 345'; + + for ($i = 0; 50000 > $i; ++$i) { + preg_match_all('/\d+/', $text, $matches); + } diff --git a/fixtures/benchmarks/match-with-preg-match.yaml b/fixtures/benchmarks/match-with-preg-match.yaml new file mode 100644 index 0000000..98521a4 --- /dev/null +++ b/fixtures/benchmarks/match-with-preg-match.yaml @@ -0,0 +1,24 @@ +slug: match-with-preg-match +name: 'Match With Preg Match' +category: Regex +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $text = 'Email: test@example.com, Phone: 123-456-7890'; + + for ($i = 0; 50000 > $i; ++$i) { + preg_match('/[\w.-]+@[\w.-]+\.\w+/', $text, $matches); + } diff --git a/fixtures/benchmarks/merge-with-array-merge-unpack.yaml b/fixtures/benchmarks/merge-with-array-merge-unpack.yaml new file mode 100644 index 0000000..2cd1e16 --- /dev/null +++ b/fixtures/benchmarks/merge-with-array-merge-unpack.yaml @@ -0,0 +1,21 @@ +slug: merge-with-array-merge-unpack +name: 'Merge With Array Merge Unpack' +category: UnpackingDestructuring +description: '' +tags: { } +phpVersions: + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $array1 = range(1, 50); + $array2 = range(51, 100); + $array3 = range(101, 150); + + for ($i = 0; 1000 > $i; ++$i) { + $result = [...$array1, ...$array2, ...$array3]; + } diff --git a/fixtures/benchmarks/merge-with-array-merge.yaml b/fixtures/benchmarks/merge-with-array-merge.yaml new file mode 100644 index 0000000..b1d1cca --- /dev/null +++ b/fixtures/benchmarks/merge-with-array-merge.yaml @@ -0,0 +1,26 @@ +slug: merge-with-array-merge +name: 'Merge With Array Merge' +category: ArrayMerge +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $array1 = range(1, 100); + $array2 = range(101, 200); + $array3 = range(201, 300); + + for ($i = 0; 10000 > $i; ++$i) { + $result = array_merge($array1, $array2, $array3); + } diff --git a/fixtures/benchmarks/merge-with-spread-operator.yaml b/fixtures/benchmarks/merge-with-spread-operator.yaml new file mode 100644 index 0000000..be51d80 --- /dev/null +++ b/fixtures/benchmarks/merge-with-spread-operator.yaml @@ -0,0 +1,21 @@ +slug: merge-with-spread-operator +name: 'Merge With Spread Operator' +category: ArrayMerge +description: '' +tags: { } +phpVersions: + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $array1 = range(1, 100); + $array2 = range(101, 200); + $array3 = range(201, 300); + + for ($i = 0; 10000 > $i; ++$i) { + $result = [...$array1, ...$array2, ...$array3]; + } diff --git a/fixtures/benchmarks/object-check-method-with-is-callable.yaml b/fixtures/benchmarks/object-check-method-with-is-callable.yaml new file mode 100644 index 0000000..9cfe9ab --- /dev/null +++ b/fixtures/benchmarks/object-check-method-with-is-callable.yaml @@ -0,0 +1,34 @@ +slug: check-method-with-is-callable +name: 'Check Method with is_callable()' +category: 'Object Operations' +description: 'Check if method exists using is_callable()' +icon: 📞 +tags: + - object + - method + - callable + - check +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + class TestClass { + public function testMethod() { + return true; + } + } + + $obj = new TestClass(); + for ($i = 0; $i < 100000; $i++) { + $exists = is_callable([$obj, 'testMethod']); + } diff --git a/fixtures/benchmarks/object-check-method-with-method-exists.yaml b/fixtures/benchmarks/object-check-method-with-method-exists.yaml new file mode 100644 index 0000000..0cbff09 --- /dev/null +++ b/fixtures/benchmarks/object-check-method-with-method-exists.yaml @@ -0,0 +1,34 @@ +slug: check-method-with-method-exists +name: 'Check Method with method_exists()' +category: 'Object Operations' +description: 'Check if method exists using method_exists()' +icon: 🔍 +tags: + - object + - method + - exists + - check +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + class TestClass { + public function testMethod() { + return true; + } + } + + $obj = new TestClass(); + for ($i = 0; $i < 100000; $i++) { + $exists = method_exists($obj, 'testMethod'); + } diff --git a/fixtures/benchmarks/pass-by-reference.yaml b/fixtures/benchmarks/pass-by-reference.yaml new file mode 100644 index 0000000..6f91624 --- /dev/null +++ b/fixtures/benchmarks/pass-by-reference.yaml @@ -0,0 +1,31 @@ +slug: pass-by-reference +name: 'Pass by Reference' +category: 'References' +description: 'Function calls with parameters passed by reference' +icon: 🔗 +tags: + - reference + - function + - parameters +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + function incrementByRef(&$value) { + $value++; + } + + $counter = 0; + for ($i = 0; $i < 100000; $i++) { + incrementByRef($counter); + } diff --git a/fixtures/benchmarks/pass-by-value.yaml b/fixtures/benchmarks/pass-by-value.yaml new file mode 100644 index 0000000..e444e74 --- /dev/null +++ b/fixtures/benchmarks/pass-by-value.yaml @@ -0,0 +1,31 @@ +slug: pass-by-value +name: 'Pass by Value' +category: 'References' +description: 'Function calls with parameters passed by value' +icon: 📋 +tags: + - value + - function + - parameters +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + function incrementByValue($value) { + return $value + 1; + } + + $counter = 0; + for ($i = 0; $i < 100000; $i++) { + $counter = incrementByValue($counter); + } diff --git a/fixtures/benchmarks/php8-nullsafe-with-isset.yaml b/fixtures/benchmarks/php8-nullsafe-with-isset.yaml new file mode 100644 index 0000000..fc22c0f --- /dev/null +++ b/fixtures/benchmarks/php8-nullsafe-with-isset.yaml @@ -0,0 +1,31 @@ +slug: nullsafe-with-isset-check +name: 'Nullsafe with isset() Check' +category: 'PHP 8 Features' +description: 'Use isset() to check properties before access' +icon: ☑️ +tags: + - isset + - null + - check +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + class User { + public $profile = null; + } + + $user = new User(); + for ($i = 0; $i < 100000; $i++) { + $name = isset($user->profile->name) ? $user->profile->name : null; + } diff --git a/fixtures/benchmarks/php8-nullsafe-with-nullsafe.yaml b/fixtures/benchmarks/php8-nullsafe-with-nullsafe.yaml new file mode 100644 index 0000000..e4b26c8 --- /dev/null +++ b/fixtures/benchmarks/php8-nullsafe-with-nullsafe.yaml @@ -0,0 +1,25 @@ +slug: nullsafe-with-nullsafe +name: 'Nullsafe Operator (PHP 8+)' +category: 'PHP 8 Features' +description: 'Use nullsafe operator ?-> to safely access properties' +icon: ✨ +tags: + - php8 + - nullsafe + - operator +phpVersions: + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + class User { + public $profile = null; + } + + $user = new User(); + for ($i = 0; $i < 100000; $i++) { + $name = $user?->profile?->name; + } diff --git a/fixtures/benchmarks/pipe-operator.yaml b/fixtures/benchmarks/pipe-operator.yaml new file mode 100644 index 0000000..e7dc7bc --- /dev/null +++ b/fixtures/benchmarks/pipe-operator.yaml @@ -0,0 +1,30 @@ +slug: pipe-operator +name: 'Pipe Operator Simulation' +category: 'Function Operations' +description: 'Simulates pipe operator with nested function calls' +icon: 🔀 +tags: + - function + - pipe + - composition +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + function double($x) { return $x * 2; } + function addFive($x) { return $x + 5; } + function square($x) { return $x * $x; } + + for ($i = 0; $i < 100000; $i++) { + $result = square(addFive(double(10))); + } diff --git a/fixtures/benchmarks/power-with-operator.yaml b/fixtures/benchmarks/power-with-operator.yaml new file mode 100644 index 0000000..4ebedb1 --- /dev/null +++ b/fixtures/benchmarks/power-with-operator.yaml @@ -0,0 +1,22 @@ +slug: power-with-operator +name: 'Power With Operator' +category: Numeric +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 100000 > $i; ++$i) { + $result = 2 ** 10; + } diff --git a/fixtures/benchmarks/power-with-pow.yaml b/fixtures/benchmarks/power-with-pow.yaml new file mode 100644 index 0000000..8bf64d0 --- /dev/null +++ b/fixtures/benchmarks/power-with-pow.yaml @@ -0,0 +1,22 @@ +slug: power-with-pow +name: 'Power With Pow' +category: Numeric +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 100000 > $i; ++$i) { + $result = 2 ** 10; + } diff --git a/fixtures/benchmarks/reduce-with-array-reduce.yaml b/fixtures/benchmarks/reduce-with-array-reduce.yaml new file mode 100644 index 0000000..8a39e48 --- /dev/null +++ b/fixtures/benchmarks/reduce-with-array-reduce.yaml @@ -0,0 +1,17 @@ +slug: reduce-with-array-reduce +name: 'Reduce With Array Reduce' +category: AdvancedArrays +description: '' +tags: { } +phpVersions: + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = range(1, 10000); + + array_reduce($data, fn ($carry, $item): int => $carry + $item, 0); diff --git a/fixtures/benchmarks/reduce-with-foreach.yaml b/fixtures/benchmarks/reduce-with-foreach.yaml new file mode 100644 index 0000000..86e8f6f --- /dev/null +++ b/fixtures/benchmarks/reduce-with-foreach.yaml @@ -0,0 +1,25 @@ +slug: reduce-with-foreach +name: 'Reduce With Foreach' +category: AdvancedArrays +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $data = range(1, 10000); + $result = 0; + + foreach ($data as $item) { + $result += $item; + } diff --git a/fixtures/benchmarks/replace-with-preg-replace.yaml b/fixtures/benchmarks/replace-with-preg-replace.yaml new file mode 100644 index 0000000..0e78305 --- /dev/null +++ b/fixtures/benchmarks/replace-with-preg-replace.yaml @@ -0,0 +1,23 @@ +slug: replace-with-preg-replace +name: 'Replace With Preg Replace' +category: StringReplace +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $text = 'Hello World, this is a test string for benchmarking purposes'; + for ($i = 0; 100000 > $i; ++$i) { + $result = preg_replace('/test/', 'sample', $text); + } diff --git a/fixtures/benchmarks/replace-with-str-replace.yaml b/fixtures/benchmarks/replace-with-str-replace.yaml new file mode 100644 index 0000000..cadbb69 --- /dev/null +++ b/fixtures/benchmarks/replace-with-str-replace.yaml @@ -0,0 +1,23 @@ +slug: replace-with-str-replace +name: 'Replace With Str Replace' +category: StringReplace +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $text = 'Hello World, this is a test string for benchmarking purposes'; + for ($i = 0; 100000 > $i; ++$i) { + $result = str_replace('test', 'sample', $text); + } diff --git a/fixtures/benchmarks/search-with-in-array.yaml b/fixtures/benchmarks/search-with-in-array.yaml new file mode 100644 index 0000000..ce35540 --- /dev/null +++ b/fixtures/benchmarks/search-with-in-array.yaml @@ -0,0 +1,24 @@ +slug: search-with-in-array +name: 'Search With In Array' +category: ArraySearch +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $haystack = range(1, 1000); + + for ($i = 0; 10000 > $i; ++$i) { + $result = in_array(750, $haystack, true); + } diff --git a/fixtures/benchmarks/search-with-isset.yaml b/fixtures/benchmarks/search-with-isset.yaml new file mode 100644 index 0000000..76433ea --- /dev/null +++ b/fixtures/benchmarks/search-with-isset.yaml @@ -0,0 +1,24 @@ +slug: search-with-isset +name: 'Search With Isset' +category: ArraySearch +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $haystack = array_flip(range(1, 1000)); + + for ($i = 0; 10000 > $i; ++$i) { + $result = isset($haystack[750]); + } diff --git a/fixtures/benchmarks/search-with-preg-match.yaml b/fixtures/benchmarks/search-with-preg-match.yaml new file mode 100644 index 0000000..d5a5d81 --- /dev/null +++ b/fixtures/benchmarks/search-with-preg-match.yaml @@ -0,0 +1,23 @@ +slug: search-with-preg-match +name: 'Search With Preg Match' +category: StringSearch +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $haystack = 'The quick brown fox jumps over the lazy dog'; + for ($i = 0; 100000 > $i; ++$i) { + $result = 1 === preg_match('/fox/', $haystack); + } diff --git a/fixtures/benchmarks/search-with-str-contains.yaml b/fixtures/benchmarks/search-with-str-contains.yaml new file mode 100644 index 0000000..941b691 --- /dev/null +++ b/fixtures/benchmarks/search-with-str-contains.yaml @@ -0,0 +1,23 @@ +slug: search-with-str-contains +name: 'String Contains (PHP 8+)' +category: 'String Operations' +description: 'String search using the modern str_contains() function introduced in PHP 8.0' +icon: 🔍 +tags: + - string + - search + - php8 + - str_contains + - modern +phpVersions: + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $haystack = 'The quick brown fox jumps over the lazy dog'; + for ($i = 0; 100000 > $i; ++$i) { + $result = str_contains($haystack, 'fox'); + } diff --git a/fixtures/benchmarks/search-with-strpos.yaml b/fixtures/benchmarks/search-with-strpos.yaml new file mode 100644 index 0000000..9921218 --- /dev/null +++ b/fixtures/benchmarks/search-with-strpos.yaml @@ -0,0 +1,23 @@ +slug: search-with-strpos +name: 'Search With Strpos' +category: StringSearch +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $haystack = 'The quick brown fox jumps over the lazy dog'; + for ($i = 0; 100000 > $i; ++$i) { + $result = false !== mb_strpos($haystack, 'fox'); + } diff --git a/fixtures/benchmarks/sort-with-asort.yaml b/fixtures/benchmarks/sort-with-asort.yaml new file mode 100644 index 0000000..771e056 --- /dev/null +++ b/fixtures/benchmarks/sort-with-asort.yaml @@ -0,0 +1,24 @@ +slug: sort-with-asort +name: 'Sort With Asort' +category: Sorting +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 1000 > $i; ++$i) { + $data = range(1, 1000); + shuffle($data); + asort($data); + } diff --git a/fixtures/benchmarks/sort-with-sort.yaml b/fixtures/benchmarks/sort-with-sort.yaml new file mode 100644 index 0000000..ff8c0c4 --- /dev/null +++ b/fixtures/benchmarks/sort-with-sort.yaml @@ -0,0 +1,24 @@ +slug: sort-with-sort +name: 'Sort With Sort' +category: Sorting +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 1000 > $i; ++$i) { + $data = range(1, 1000); + shuffle($data); + sort($data); + } diff --git a/fixtures/benchmarks/sort-with-usort.yaml b/fixtures/benchmarks/sort-with-usort.yaml new file mode 100644 index 0000000..959b146 --- /dev/null +++ b/fixtures/benchmarks/sort-with-usort.yaml @@ -0,0 +1,19 @@ +slug: sort-with-usort +name: 'Sort With Usort' +category: Sorting +description: '' +tags: { } +phpVersions: + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + for ($i = 0; 1000 > $i; ++$i) { + $data = range(1, 1000); + shuffle($data); + usort($data, fn ($a, $b): int => $a <=> $b); + } diff --git a/fixtures/benchmarks/split-with-explode.yaml b/fixtures/benchmarks/split-with-explode.yaml new file mode 100644 index 0000000..30c0694 --- /dev/null +++ b/fixtures/benchmarks/split-with-explode.yaml @@ -0,0 +1,24 @@ +slug: split-with-explode +name: 'Split With Explode' +category: Regex +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $text = 'one,two,three,four,five'; + + for ($i = 0; 50000 > $i; ++$i) { + $result = explode(',', $text); + } diff --git a/fixtures/benchmarks/split-with-preg-split.yaml b/fixtures/benchmarks/split-with-preg-split.yaml new file mode 100644 index 0000000..25fc54c --- /dev/null +++ b/fixtures/benchmarks/split-with-preg-split.yaml @@ -0,0 +1,24 @@ +slug: split-with-preg-split +name: 'Split With Preg Split' +category: Regex +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $text = 'one,two,three,four,five'; + + for ($i = 0; 50000 > $i; ++$i) { + $result = preg_split('/,/', $text); + } diff --git a/fixtures/benchmarks/static-access-instance-property.yaml b/fixtures/benchmarks/static-access-instance-property.yaml new file mode 100644 index 0000000..aeb044e --- /dev/null +++ b/fixtures/benchmarks/static-access-instance-property.yaml @@ -0,0 +1,32 @@ +slug: access-instance-property +name: 'Access Instance Property' +category: 'Static vs Instance' +description: 'Benchmarks accessing an instance property' +icon: 📊 +tags: + - static + - instance + - property + - oop +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + class TestClass { + public $value = 42; + } + + $obj = new TestClass(); + for ($i = 0; $i < 100000; $i++) { + $result = $obj->value; + } diff --git a/fixtures/benchmarks/static-access-static-property.yaml b/fixtures/benchmarks/static-access-static-property.yaml new file mode 100644 index 0000000..f08bbfc --- /dev/null +++ b/fixtures/benchmarks/static-access-static-property.yaml @@ -0,0 +1,30 @@ +slug: access-static-property +name: 'Access Static Property' +category: 'Static vs Instance' +description: 'Benchmarks accessing a static property' +icon: 📊 +tags: + - static + - property + - oop +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + class TestClass { + public static $value = 42; + } + + for ($i = 0; $i < 100000; $i++) { + $result = TestClass::$value; + } diff --git a/fixtures/benchmarks/static-call-instance-method.yaml b/fixtures/benchmarks/static-call-instance-method.yaml new file mode 100644 index 0000000..e70fd69 --- /dev/null +++ b/fixtures/benchmarks/static-call-instance-method.yaml @@ -0,0 +1,34 @@ +slug: call-instance-method +name: 'Call Instance Method' +category: 'Static vs Instance' +description: 'Benchmarks calling an instance method' +icon: 🔧 +tags: + - static + - instance + - method + - oop +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + class TestClass { + public function getValue() { + return 42; + } + } + + $obj = new TestClass(); + for ($i = 0; $i < 100000; $i++) { + $result = $obj->getValue(); + } diff --git a/fixtures/benchmarks/static-call-static-method.yaml b/fixtures/benchmarks/static-call-static-method.yaml new file mode 100644 index 0000000..e7a7d61 --- /dev/null +++ b/fixtures/benchmarks/static-call-static-method.yaml @@ -0,0 +1,32 @@ +slug: call-static-method +name: 'Call Static Method' +category: 'Static vs Instance' +description: 'Benchmarks calling a static method' +icon: 🔧 +tags: + - static + - method + - oop +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + class TestClass { + public static function getValue() { + return 42; + } + } + + for ($i = 0; $i < 100000; $i++) { + $result = TestClass::getValue(); + } diff --git a/fixtures/benchmarks/str-ends-with-function.yaml b/fixtures/benchmarks/str-ends-with-function.yaml new file mode 100644 index 0000000..128eb4e --- /dev/null +++ b/fixtures/benchmarks/str-ends-with-function.yaml @@ -0,0 +1,18 @@ +slug: str-ends-with-function +name: 'Str Ends With Function' +category: Php8Features +description: '' +tags: { } +phpVersions: + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $text = 'Hello World'; + + for ($i = 0; 100000 > $i; ++$i) { + $result = str_ends_with($text, 'World'); + } diff --git a/fixtures/benchmarks/str-starts-with-function.yaml b/fixtures/benchmarks/str-starts-with-function.yaml new file mode 100644 index 0000000..3bda885 --- /dev/null +++ b/fixtures/benchmarks/str-starts-with-function.yaml @@ -0,0 +1,18 @@ +slug: str-starts-with-function +name: 'Str Starts With Function' +category: Php8Features +description: '' +tags: { } +phpVersions: + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $text = 'Hello World'; + + for ($i = 0; 100000 > $i; ++$i) { + $result = str_starts_with($text, 'Hello'); + } diff --git a/fixtures/benchmarks/str-starts-with-substr.yaml b/fixtures/benchmarks/str-starts-with-substr.yaml new file mode 100644 index 0000000..ebdcb8e --- /dev/null +++ b/fixtures/benchmarks/str-starts-with-substr.yaml @@ -0,0 +1,24 @@ +slug: str-starts-with-substr +name: 'Str Starts With Substr' +category: Php8Features +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $text = 'Hello World'; + + for ($i = 0; 100000 > $i; ++$i) { + $result = 'Hello' === mb_substr($text, 0, 5); + } diff --git a/fixtures/benchmarks/substr-with-mb-substr.yaml b/fixtures/benchmarks/substr-with-mb-substr.yaml new file mode 100644 index 0000000..a21f3d0 --- /dev/null +++ b/fixtures/benchmarks/substr-with-mb-substr.yaml @@ -0,0 +1,24 @@ +slug: substr-with-mb-substr +name: 'Substr With Mb Substr' +category: AdvancedStrings +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $text = 'Hello World, this is a test string for benchmarking'; + + for ($i = 0; 100000 > $i; ++$i) { + $result = mb_substr($text, 0, 10); + } diff --git a/fixtures/benchmarks/substr-with-substr.yaml b/fixtures/benchmarks/substr-with-substr.yaml new file mode 100644 index 0000000..a3ac581 --- /dev/null +++ b/fixtures/benchmarks/substr-with-substr.yaml @@ -0,0 +1,24 @@ +slug: substr-with-substr +name: 'Substr With Substr' +category: AdvancedStrings +description: '' +tags: { } +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $text = 'Hello World, this is a test string for benchmarking'; + + for ($i = 0; 100000 > $i; ++$i) { + $result = mb_substr($text, 0, 10); + } diff --git a/fixtures/benchmarks/unpack-with-call-user-func-array.yaml b/fixtures/benchmarks/unpack-with-call-user-func-array.yaml new file mode 100644 index 0000000..bd8ff08 --- /dev/null +++ b/fixtures/benchmarks/unpack-with-call-user-func-array.yaml @@ -0,0 +1,31 @@ +slug: unpack-with-call-user-func-array +name: 'Unpack with call_user_func_array()' +category: 'Unpacking' +description: 'Unpack array arguments using call_user_func_array()' +icon: 📤 +tags: + - unpack + - array + - function +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + function sum($a, $b, $c) { + return $a + $b + $c; + } + + $args = [1, 2, 3]; + for ($i = 0; $i < 100000; $i++) { + $result = call_user_func_array('sum', $args); + } diff --git a/fixtures/benchmarks/unpack-with-spread-operator.yaml b/fixtures/benchmarks/unpack-with-spread-operator.yaml new file mode 100644 index 0000000..9094637 --- /dev/null +++ b/fixtures/benchmarks/unpack-with-spread-operator.yaml @@ -0,0 +1,32 @@ +slug: unpack-with-spread-operator +name: 'Unpack with Spread Operator' +category: 'Unpacking' +description: 'Unpack array arguments using spread operator ... (PHP 5.6+)' +icon: 💫 +tags: + - unpack + - spread + - operator + - php56 +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + function sum($a, $b, $c) { + return $a + $b + $c; + } + + $args = [1, 2, 3]; + for ($i = 0; $i < 100000; $i++) { + $result = sum(...$args); + } diff --git a/fixtures/benchmarks/variable-access-with-global.yaml b/fixtures/benchmarks/variable-access-with-global.yaml new file mode 100644 index 0000000..21ffeb2 --- /dev/null +++ b/fixtures/benchmarks/variable-access-with-global.yaml @@ -0,0 +1,33 @@ +slug: access-with-global +name: 'Access Global Variable' +category: 'Variable Optimization' +description: 'Access variable using global keyword' +icon: 🌍 +tags: + - global + - variable + - scope +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + $globalValue = 42; + + function accessGlobal() { + global $globalValue; + return $globalValue; + } + + for ($i = 0; $i < 100000; $i++) { + $result = accessGlobal(); + } diff --git a/fixtures/benchmarks/variable-access-with-parameter.yaml b/fixtures/benchmarks/variable-access-with-parameter.yaml new file mode 100644 index 0000000..4f4311c --- /dev/null +++ b/fixtures/benchmarks/variable-access-with-parameter.yaml @@ -0,0 +1,31 @@ +slug: access-with-parameter +name: 'Access via Parameter' +category: 'Variable Optimization' +description: 'Access variable by passing it as parameter' +icon: 📥 +tags: + - parameter + - variable + - scope +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + function accessParameter($value) { + return $value; + } + + $value = 42; + for ($i = 0; $i < 100000; $i++) { + $result = accessParameter($value); + } diff --git a/fixtures/benchmarks/variable-define-with-const.yaml b/fixtures/benchmarks/variable-define-with-const.yaml new file mode 100644 index 0000000..3e3551b --- /dev/null +++ b/fixtures/benchmarks/variable-define-with-const.yaml @@ -0,0 +1,28 @@ +slug: define-with-const +name: 'Define with const' +category: 'Variable Optimization' +description: 'Define constant using const keyword' +icon: 🔒 +tags: + - const + - constant + - define +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + const MY_CONSTANT = 42; + + for ($i = 0; $i < 100000; $i++) { + $result = MY_CONSTANT; + } diff --git a/fixtures/benchmarks/variable-define-with-define.yaml b/fixtures/benchmarks/variable-define-with-define.yaml new file mode 100644 index 0000000..b682add --- /dev/null +++ b/fixtures/benchmarks/variable-define-with-define.yaml @@ -0,0 +1,27 @@ +slug: define-with-define +name: 'Define with define()' +category: 'Variable Optimization' +description: 'Define constant using define() function' +icon: 🔑 +tags: + - define + - constant +phpVersions: + - php56 + - php70 + - php71 + - php72 + - php73 + - php74 + - php80 + - php81 + - php82 + - php83 + - php84 + - php85 +code: | + define('MY_CONSTANT', 42); + + for ($i = 0; $i < 100000; $i++) { + $result = constant('MY_CONSTANT'); + } diff --git a/importmap.php b/importmap.php index 41cc5da..4693320 100644 --- a/importmap.php +++ b/importmap.php @@ -1,5 +1,7 @@ [ 'version' => '0.3.4', ], + '@symfony/ux-live-component' => [ + 'path' => './vendor/symfony/ux-live-component/assets/dist/live_controller.js', + ], ]; diff --git a/infection.json5 b/infection.json5 new file mode 100644 index 0000000..cb03814 --- /dev/null +++ b/infection.json5 @@ -0,0 +1,43 @@ +{ + // Infection - Mutation Testing Configuration + // See: https://infection.readthedocs.io/ + + "$schema": "vendor/infection/infection/resources/schema.json", + + // Source code directories to mutate + "source": { + "directories": ["src/Domain", "src/Application"], + "excludes": [ + "src/Domain/Benchmark/Test", + "src/Domain/PhpVersion/Enum/PhpVersion.php" + ] + }, + + // Test configuration + "phpUnit": { + "configDir": "." + }, + + // Logging options + "logs": { + "text": "var/infection/infection.log", + "html": "var/infection/html", + "summary": "var/infection/summary.log" + }, + + // Minimum required mutation score + "minMsi": 80, + "minCoveredMsi": 85, + + // Timeout for mutated tests (in seconds) + "timeout": 10, + + // Which mutators to run + // See: https://infection.readthedocs.io/en/latest/mutators.html + "mutators": { + "@default": true + }, + + // Report options + "testFramework": "phpunit" +} diff --git a/migrations/Version20251103175019.php b/migrations/Version20251103175019.php new file mode 100644 index 0000000..105bc22 --- /dev/null +++ b/migrations/Version20251103175019.php @@ -0,0 +1,31 @@ +addSql('CREATE TABLE benchmarks (id INT AUTO_INCREMENT NOT NULL, slug VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, category VARCHAR(100) NOT NULL, description LONGTEXT NOT NULL, code LONGTEXT NOT NULL, tags JSON NOT NULL COMMENT \'(DC2Type:json)\', icon VARCHAR(10) DEFAULT NULL, php_versions JSON NOT NULL COMMENT \'(DC2Type:json)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_41BC1C58989D9B62 (slug), INDEX idx_category (category), INDEX idx_slug (slug), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE benchmarks'); + } +} diff --git a/phparkitect.php b/phparkitect.php index 8d85875..b2d4c1c 100644 --- a/phparkitect.php +++ b/phparkitect.php @@ -9,6 +9,33 @@ use Arkitect\Expression\ForClasses\ResideInOneOfTheseNamespaces; use Arkitect\Rules\Rule; +/** + * Helper to exclude native PHP classes from architecture rules. + * Native PHP classes (exceptions, attributes, reflection) are acceptable dependencies. + */ +function allowPhpNativeClasses(string ...$allowedNamespaces): NotHaveDependencyOutsideNamespace +{ + $nativePhpClasses = [ + // PHP Native Exceptions + 'Exception', + 'RuntimeException', + 'InvalidArgumentException', + 'LogicException', + 'DomainException', + + // PHP Native Classes + 'Attribute', + 'ReflectionClass', + 'ReflectionMethod', + 'ReflectionParameter', + 'DateTimeImmutable', + 'DateTime', + 'stdClass', + ]; + + return new NotHaveDependencyOutsideNamespace($allowedNamespaces[0], array_merge(array_slice($allowedNamespaces, 1), $nativePhpClasses)); +} + return static function (Config $config): void { $classSet = ClassSet::fromDir(__DIR__ . '/src'); @@ -49,18 +76,18 @@ // CLEAN ARCHITECTURE - LAYER RULES // ======================================== - // Rule 1: Domain Layer - NO external dependencies + // Rule 1: Domain Layer - NO external dependencies (except native PHP) // Le Domain ne doit dépendre de RIEN (ni Application, ni Infrastructure, ni frameworks) $rules[] = Rule::allClasses() ->that(new ResideInOneOfTheseNamespaces('Jblairy\PhpBenchmark\Domain')) - ->should(new NotHaveDependencyOutsideNamespace('Jblairy\PhpBenchmark\Domain')) + ->should(allowPhpNativeClasses('Jblairy\PhpBenchmark\Domain')) ->because('Domain must not depend on Application or Infrastructure - Clean Architecture principle'); - // Rule 2: Application Layer - can only depend on Domain + // Rule 2: Application Layer - can only depend on Domain (except native PHP) // L'Application peut utiliser le Domain, mais pas l'Infrastructure $rules[] = Rule::allClasses() ->that(new ResideInOneOfTheseNamespaces('Jblairy\PhpBenchmark\Application')) - ->should(new NotHaveDependencyOutsideNamespace('Jblairy\PhpBenchmark\Application', ['Jblairy\PhpBenchmark\Domain'])) + ->should(allowPhpNativeClasses('Jblairy\PhpBenchmark\Application', 'Jblairy\PhpBenchmark\Domain')) ->because('Application layer can only depend on Domain layer, not on Infrastructure'); // Rule 3: Infrastructure can depend on Domain and Application (but Application should be minimal) @@ -93,7 +120,7 @@ ->should(new ResideInOneOfTheseNamespaces( 'Jblairy\PhpBenchmark\Infrastructure\Persistence\Doctrine\Repository', 'Jblairy\PhpBenchmark\Infrastructure\Persistence\InMemory', - 'Jblairy\PhpBenchmark\Domain\Benchmark\Port' + 'Jblairy\PhpBenchmark\Domain\Benchmark\Port', )) ->because('Repositories must be in Infrastructure (concrete) or Domain/Port (interface)'); @@ -104,7 +131,7 @@ // Rule 8: Value Objects and Entities should be in Domain $rules[] = Rule::allClasses() ->that(new ResideInOneOfTheseNamespaces('Jblairy\PhpBenchmark\Domain\Benchmark\Model')) - ->should(new NotHaveDependencyOutsideNamespace('Jblairy\PhpBenchmark\Domain')) + ->should(allowPhpNativeClasses('Jblairy\PhpBenchmark\Domain')) ->because('Domain Models must not depend on external layers'); // ======================================== @@ -115,7 +142,7 @@ // Les tests de benchmark sont des concepts métier $rules[] = Rule::allClasses() ->that(new ResideInOneOfTheseNamespaces('Jblairy\PhpBenchmark\Domain\Benchmark\Test')) - ->should(new NotHaveDependencyOutsideNamespace('Jblairy\PhpBenchmark\Domain')) + ->should(allowPhpNativeClasses('Jblairy\PhpBenchmark\Domain')) ->because('Benchmark tests are domain concepts and must not depend on external layers'); // Rule 10: Controllers must be in Infrastructure/Web @@ -127,7 +154,7 @@ // Rule 11: Use Cases must be in Application and only depend on Domain $rules[] = Rule::allClasses() ->that(new ResideInOneOfTheseNamespaces('Jblairy\PhpBenchmark\Application\UseCase')) - ->should(new NotHaveDependencyOutsideNamespace('Jblairy\PhpBenchmark\Application', ['Jblairy\PhpBenchmark\Domain'])) + ->should(allowPhpNativeClasses('Jblairy\PhpBenchmark\Application', 'Jblairy\PhpBenchmark\Domain')) ->because('Use Cases orchestrate domain logic and must not depend on Infrastructure'); // Rule 12: Commands must be in Infrastructure/Cli @@ -139,7 +166,7 @@ // Rule 13: Domain Services must not depend on Infrastructure $rules[] = Rule::allClasses() ->that(new ResideInOneOfTheseNamespaces('Jblairy\PhpBenchmark\Domain\Benchmark\Service')) - ->should(new NotHaveDependencyOutsideNamespace('Jblairy\PhpBenchmark\Domain')) + ->should(allowPhpNativeClasses('Jblairy\PhpBenchmark\Domain')) ->because('Domain Services must remain pure and only depend on Domain layer'); // Rule 14: Entities (Doctrine) must be in Infrastructure @@ -152,13 +179,7 @@ // ANTI-CORRUPTION LAYER RULES // ======================================== - // Rule 15: PhpVersion is a Domain concept - $rules[] = Rule::allClasses() - ->that(new ResideInOneOfTheseNamespaces('Jblairy\PhpBenchmark\Domain\PhpVersion')) - ->should(new NotHaveDependencyOutsideNamespace('Jblairy\PhpBenchmark\Domain')) - ->because('PhpVersion is a domain concept and must remain pure'); - - // Rule 16: Adapters/Implementations must be in Infrastructure + // Rule 15: Adapters/Implementations must be in Infrastructure // Les adaptateurs (implémentations des Ports) doivent être dans Infrastructure $rules[] = Rule::allClasses() ->that(new HaveNameMatching('*Adapter')) @@ -172,13 +193,13 @@ // Rule 17: Contracts/Interfaces must be in Domain $rules[] = Rule::allClasses() ->that(new ResideInOneOfTheseNamespaces('Jblairy\PhpBenchmark\Domain\Benchmark\Contract')) - ->should(new NotHaveDependencyOutsideNamespace('Jblairy\PhpBenchmark\Domain')) + ->should(allowPhpNativeClasses('Jblairy\PhpBenchmark\Domain')) ->because('Contracts are core domain concepts and must not depend on external layers'); - // Rule 18: Application Services (ex: ChartBuilder) must be in Application + // Rule 18: Application Services must be in Application and only depend on Domain $rules[] = Rule::allClasses() ->that(new ResideInOneOfTheseNamespaces('Jblairy\PhpBenchmark\Application\Service')) - ->should(new NotHaveDependencyOutsideNamespace('Jblairy\PhpBenchmark\Application', ['Jblairy\PhpBenchmark\Domain'])) + ->should(allowPhpNativeClasses('Jblairy\PhpBenchmark\Application', 'Jblairy\PhpBenchmark\Domain')) ->because('Application Services must not depend on Infrastructure or external frameworks'); $config->add($classSet, ...$rules); diff --git a/phpstan.dist.neon b/phpstan.dist.neon index fbf1c8a..62675cc 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -13,7 +13,7 @@ parameters: - var/ - vendor/ - tests/bootstrap.php - - src/Domain/Benchmark/Test + - src/Domain/Benchmark/Test (?) checkMissingCallableSignature: true checkMissingVarTagTypehint: true @@ -48,6 +48,18 @@ parameters: maximumNumberOfProcesses: 32 minimumNumberOfJobsPerProcess: 2 + ignoreErrors: + # Test assertions that PHPStan considers redundant are intentional for documentation + - + message: '#Call to static method PHPUnit\\Framework\\Assert::assertSame\(\) .* will always evaluate to true#' + path: tests/ + - + message: '#Result of .* is always (true|false)#' + path: tests/ + - + message: '#Strict comparison using === .* will always evaluate to (true|false)#' + path: tests/ + includes: - vendor/phpstan/phpstan-symfony/extension.neon - vendor/phpstan/phpstan-phpunit/extension.neon diff --git a/public/index.php b/public/index.php index c52526f..536aeab 100644 --- a/public/index.php +++ b/public/index.php @@ -6,6 +6,4 @@ require_once dirname(__DIR__) . '/vendor/autoload_runtime.php'; -return function (array $context) { - return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); -}; +return fn (array $context): Kernel => new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..36ef288 --- /dev/null +++ b/rector.php @@ -0,0 +1,19 @@ +withPaths([ + __DIR__ . '/assets', + __DIR__ . '/config', + __DIR__ . '/public', + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + // uncomment to reach your current PHP version + ->withPhpSets(php84: true) + ->withAttributesSets(symfony: true, doctrine: true, gedmo: true, phpunit: true, sensiolabs: true) + ->withComposerBased(twig: true, phpunit: true, doctrine: true, symfony: true, netteUtils: true) + ->withPreparedSets(deadCode: true, codeQuality: true, codingStyle: true, typeDeclarations: true, doctrineCodeQuality: true, naming: true, instanceOf: true, earlyReturn: true, strictBooleans: true, phpunitCodeQuality: true, rectorPreset: true, symfonyCodeQuality: true, symfonyConfigs: true, privatization: true); diff --git a/rulesets.xml b/rulesets.xml index 5c9d968..14a6be1 100644 --- a/rulesets.xml +++ b/rulesets.xml @@ -10,8 +10,8 @@ - - + + @@ -20,12 +20,18 @@ + + + + + + diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..c31a314 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,435 @@ +# Utility Scripts + +This directory contains utility scripts for working with the PHP Benchmark project. + +## Mercure Scripts + +### mercure-verify.sh + +**Purpose**: Verifies that Mercure is properly configured and working + +**Usage**: +```bash +./scripts/mercure-verify.sh +``` + +**What it checks**: +- ✅ Mercure container is running +- ✅ Mercure is accessible via HTTP +- ✅ Port 3000 is properly mapped +- ✅ Environment variables are set correctly +- ✅ Mercure bundle configuration exists +- ✅ Event subscriber is registered +- ✅ No errors in recent logs +- ✅ CORS is configured + +**Output example**: +``` +╔════════════════════════════════════════════════════════════════╗ +║ Mercure Configuration Verification ║ +╚════════════════════════════════════════════════════════════════╝ + +1. Checking Mercure container status... + ✅ Mercure container is running + +2. Checking Mercure HTTP accessibility... + ✅ Mercure is accessible (HTTP 400 expected) + +... + +Summary: 15 passed, 0 failed +════════════════════════════════════════════════════════════════ +🎉 All checks passed! Mercure is working correctly. +``` + +**Exit codes**: +- `0` - All checks passed +- `1` - One or more checks failed + +--- + +### mercure-listen.sh + +**Purpose**: Listen to Mercure events with formatted output + +**Usage**: +```bash +./scripts/mercure-listen.sh [topic] [format] +``` + +**Parameters**: +- `topic` (optional): Mercure topic to subscribe to (default: `benchmark/progress`) +- `format` (optional): Output format - `raw`, `json`, `pretty`, `stats` (default: `pretty`) + +**Formats**: + +**1. pretty** (default) - Formatted output with colors and timestamps +```bash +./scripts/mercure-listen.sh +# or +./scripts/mercure-listen.sh benchmark/progress pretty +``` + +Output: +``` +📨 Event ID: bc8bc7a8-e0c5-40af-8547-dd21fc4a7306 +⏱️ benchmark.started at 12:34:56 + Benchmark: Loop on php84 (5 iterations) + +📨 Event ID: 11031f73-897a-4d97-bf13-debe15b5ef20 +🔄 benchmark.progress at 12:34:57 + Loop: 1/5 (20%) +``` + +**2. raw** - Raw Server-Sent Events format +```bash +./scripts/mercure-listen.sh benchmark/progress raw +``` + +Output: +``` +id: urn:uuid:bc8bc7a8-e0c5-40af-8547-dd21fc4a7306 +data: {"type":"benchmark.started","benchmarkId":"Loop",...} + +id: urn:uuid:11031f73-897a-4d97-bf13-debe15b5ef20 +data: {"type":"benchmark.progress","currentIteration":1,...} +``` + +**3. json** - JSON only (requires `jq`) +```bash +./scripts/mercure-listen.sh benchmark/progress json +``` + +Output: +```json +{ + "type": "benchmark.started", + "benchmarkId": "Jblairy\\PhpBenchmark\\Domain\\Benchmark\\Test\\Loop", + "benchmarkName": "Loop", + "phpVersion": "php84", + "totalIterations": 5, + "timestamp": 1761113607 +} +``` + +**4. stats** - Event statistics +```bash +./scripts/mercure-listen.sh benchmark/progress stats +``` + +Output (updates in real-time): +``` +Event Statistics (Ctrl+C to stop and show results) + +Started: 1 | Progress: 5 | Completed: 1 +``` + +**Requirements**: +- `jq` (optional, for `json` and `pretty` formats) + ```bash + apt-get install jq # Debian/Ubuntu + ``` + +--- + +### mercure-test.sh + +**Purpose**: End-to-end test of Mercure real-time events + +**Usage**: +```bash +./scripts/mercure-test.sh [iterations] [test] [version] +``` + +**Parameters**: +- `iterations` (optional): Number of iterations (default: 3) +- `test` (optional): Benchmark to run (default: Loop) +- `version` (optional): PHP version (default: php84) + +**Examples**: + +**Quick test** (3 iterations): +```bash +./scripts/mercure-test.sh +``` + +**Custom test**: +```bash +./scripts/mercure-test.sh 10 Loop php84 +``` + +**What it does**: +1. Starts listening to Mercure in background +2. Runs the specified benchmark +3. Captures all SSE events +4. Validates event counts +5. Shows results and sample event + +**Output example**: +``` +╔════════════════════════════════════════════════════════════════╗ +║ Mercure End-to-End Test ║ +╚════════════════════════════════════════════════════════════════╝ + +Configuration: + Test: Loop + PHP Version: php84 + Iterations: 3 + +Starting event listener in background... +Running benchmark... + +Running Loop on php84 (3 iterations) +==================================== + [OK] Benchmark(s) completed successfully! + +════════════════════════════════════════════════════════════════ +Results: +════════════════════════════════════════════════════════════════ + +Events received: + 📨 Total events: 5 + 🚀 Started: 1 + 🔄 Progress: 3 + ✅ Completed: 1 + +Expected: + 📨 Total events: 5 + 🚀 Started: 1 + 🔄 Progress: 3 + ✅ Completed: 1 + +════════════════════════════════════════════════════════════════ +🎉 All tests passed! Mercure real-time events are working correctly. + +Sample event: +{ + "type": "benchmark.started", + "benchmarkId": "Jblairy\\PhpBenchmark\\Domain\\Benchmark\\Test\\Loop", + "benchmarkName": "Loop", + "phpVersion": "php84", + "totalIterations": 3, + "timestamp": 1761113981 +} +``` + +**Exit codes**: +- `0` - All tests passed +- `1` - Test failed + +--- + +## Common Workflows + +### 1. Initial Setup Verification + +After setting up the project, verify Mercure is working: + +```bash +# 1. Verify configuration +./scripts/mercure-verify.sh + +# 2. Run end-to-end test +./scripts/mercure-test.sh + +# 3. If all passes, you're ready! +``` + +--- + +### 2. Debug Real-Time Issues + +If events are not showing in the browser: + +```bash +# Terminal 1: Listen to events +./scripts/mercure-listen.sh + +# Terminal 2: Run a benchmark +make run test=Loop iterations=5 version=php84 + +# Check if Terminal 1 receives events +# If yes: Problem is in browser/JavaScript +# If no: Problem is in Symfony publishing +``` + +--- + +### 3. Monitor Benchmark Execution + +Watch benchmark progress in real-time: + +```bash +# Start listener with pretty formatting +./scripts/mercure-listen.sh benchmark/progress pretty + +# In another terminal, run benchmarks +make run iterations=10 +``` + +--- + +### 4. Automated Testing (CI/CD) + +Use in CI/CD pipelines: + +```bash +#!/bin/bash +# CI/CD test script + +# Start services +docker-compose up -d + +# Wait for services to be ready +sleep 5 + +# Verify Mercure +if ! ./scripts/mercure-verify.sh; then + echo "Mercure verification failed" + exit 1 +fi + +# Run end-to-end test +if ! ./scripts/mercure-test.sh 5 Loop php84; then + echo "Mercure E2E test failed" + exit 1 +fi + +echo "All Mercure tests passed!" +``` + +--- + +### 5. Event Analysis + +Capture and analyze events: + +```bash +# Capture events to file (run for 60 seconds) +timeout 60 ./scripts/mercure-listen.sh benchmark/progress raw > events.log & + +# Run benchmarks +make run iterations=100 + +# Wait for capture to complete +wait + +# Analyze events +grep '"benchmark.progress"' events.log | wc -l # Count progress events +grep '"progress":100' events.log # Find 100% completion +``` + +--- + +## Tips & Tricks + +### Quick Health Check + +```bash +# One-line health check +./scripts/mercure-verify.sh && echo "✅ Mercure is healthy" +``` + +### Watch Events with Colors + +```bash +# Best for monitoring during development +./scripts/mercure-listen.sh benchmark/progress pretty +``` + +### Count Events by Type + +```bash +# Use stats mode to see event distribution +./scripts/mercure-listen.sh benchmark/progress stats +``` + +### Extract Specific Data + +```bash +# Extract only progress percentages +./scripts/mercure-listen.sh benchmark/progress json | jq -r '.progress // empty' + +# Extract benchmark names +./scripts/mercure-listen.sh benchmark/progress json | jq -r '.benchmarkName // empty' | sort -u +``` + +### Test Specific Scenarios + +```bash +# Test slow benchmark +./scripts/mercure-test.sh 100 HashWithSha256 php84 + +# Test multiple versions +for version in php56 php70 php80 php84; do + ./scripts/mercure-test.sh 5 Loop $version +done +``` + +--- + +## Troubleshooting + +### Script exits immediately + +**Symptom**: `mercure-listen.sh` exits right after starting + +**Cause**: No events are being published + +**Solution**: +1. Run `./scripts/mercure-verify.sh` to check configuration +2. Check if benchmarks are actually running +3. Check Mercure logs: `docker-compose logs -f mercure` + +--- + +### "jq: command not found" + +**Symptom**: Error when using `json` or `pretty` format + +**Solution**: +```bash +# Install jq +sudo apt-get install jq # Debian/Ubuntu +brew install jq # macOS +``` + +Or use `raw` format which doesn't require jq. + +--- + +### Permission denied + +**Symptom**: `./scripts/mercure-*.sh: Permission denied` + +**Solution**: +```bash +chmod +x scripts/mercure-*.sh +``` + +--- + +### Docker not running + +**Symptom**: `Cannot connect to Docker daemon` + +**Solution**: +```bash +# Start Docker +sudo systemctl start docker + +# Or start via docker-compose +docker-compose up -d +``` + +--- + +## See Also + +- [Mercure Practical Guide](../docs/infrastructure/mercure-practical-guide.md) - Detailed debugging and usage +- [Mercure Real-Time Documentation](../docs/infrastructure/mercure-realtime.md) - Architecture and configuration +- [Docker Infrastructure](../docs/infrastructure/docker.md) - Overall Docker setup + +--- + +**Last updated**: 2025-10-22 diff --git a/scripts/mercure-listen.sh b/scripts/mercure-listen.sh new file mode 100755 index 0000000..3eec845 --- /dev/null +++ b/scripts/mercure-listen.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# Listen to Mercure events with formatted output + +TOPIC=${1:-"benchmark/progress"} +FORMAT=${2:-"pretty"} + +echo "╔════════════════════════════════════════════════════════════════╗" +echo "║ Listening to Mercure Events ║" +echo "╚════════════════════════════════════════════════════════════════╝" +echo "" +echo "Topic: $TOPIC" +echo "Format: $FORMAT" +echo "Press Ctrl+C to stop" +echo "" +echo "Waiting for events..." +echo "────────────────────────────────────────────────────────────────" +echo "" + +if [ "$FORMAT" = "raw" ]; then + # Raw SSE format + curl -N "http://localhost:3000/.well-known/mercure?topic=${TOPIC}" +elif [ "$FORMAT" = "json" ]; then + # JSON only (requires jq) + if ! command -v jq &> /dev/null; then + echo "Error: jq is required for JSON format" + echo "Install: apt-get install jq" + exit 1 + fi + curl -N "http://localhost:3000/.well-known/mercure?topic=${TOPIC}" | \ + grep '^data:' | sed 's/^data: //' | jq . +elif [ "$FORMAT" = "pretty" ]; then + # Pretty formatted output with timestamps and colors + if command -v jq &> /dev/null; then + curl -N "http://localhost:3000/.well-known/mercure?topic=${TOPIC}" | \ + while IFS= read -r line; do + if [[ $line == id:* ]]; then + # Extract UUID + UUID=$(echo "$line" | sed 's/id: urn:uuid://') + echo -e "\n📨 Event ID: \033[0;36m$UUID\033[0m" + elif [[ $line == data:* ]]; then + # Parse and pretty-print JSON + DATA=$(echo "$line" | sed 's/^data: //') + TYPE=$(echo "$DATA" | jq -r '.type') + TIMESTAMP=$(echo "$DATA" | jq -r '.timestamp') + TIME=$(date -d "@$TIMESTAMP" '+%H:%M:%S' 2>/dev/null || echo "$TIMESTAMP") + + case "$TYPE" in + "benchmark.started") + BENCH=$(echo "$DATA" | jq -r '.benchmarkName') + PHP=$(echo "$DATA" | jq -r '.phpVersion') + ITER=$(echo "$DATA" | jq -r '.totalIterations') + echo -e "⏱️ \033[1;32m$TYPE\033[0m at $TIME" + echo -e " Benchmark: $BENCH on $PHP ($ITER iterations)" + ;; + "benchmark.progress") + BENCH=$(echo "$DATA" | jq -r '.benchmarkName') + CURRENT=$(echo "$DATA" | jq -r '.currentIteration') + TOTAL=$(echo "$DATA" | jq -r '.totalIterations') + PROGRESS=$(echo "$DATA" | jq -r '.progress') + echo -e "🔄 \033[1;33m$TYPE\033[0m at $TIME" + echo -e " $BENCH: $CURRENT/$TOTAL (\033[1m${PROGRESS}%\033[0m)" + ;; + "benchmark.completed") + BENCH=$(echo "$DATA" | jq -r '.benchmarkName') + PHP=$(echo "$DATA" | jq -r '.phpVersion') + echo -e "✅ \033[1;32m$TYPE\033[0m at $TIME" + echo -e " $BENCH on $PHP finished!" + ;; + *) + echo -e "📬 $TYPE at $TIME" + echo "$DATA" | jq . + ;; + esac + fi + done + else + # Fallback without jq + curl -N "http://localhost:3000/.well-known/mercure?topic=${TOPIC}" | \ + while IFS= read -r line; do + if [[ $line == id:* ]]; then + echo "" + echo "[$(date '+%H:%M:%S')] $line" + elif [[ $line == data:* ]]; then + echo "$line" + fi + done + fi +elif [ "$FORMAT" = "stats" ]; then + # Statistics mode: count events + echo "Event Statistics (Ctrl+C to stop and show results)" + echo "" + + STARTED=0 + PROGRESS=0 + COMPLETED=0 + + curl -N "http://localhost:3000/.well-known/mercure?topic=${TOPIC}" | \ + while IFS= read -r line; do + if [[ $line == data:* ]]; then + TYPE=$(echo "$line" | sed 's/^data: //' | grep -o '"type":"[^"]*"' | cut -d'"' -f4) + case "$TYPE" in + "benchmark.started") + STARTED=$((STARTED + 1)) + ;; + "benchmark.progress") + PROGRESS=$((PROGRESS + 1)) + ;; + "benchmark.completed") + COMPLETED=$((COMPLETED + 1)) + ;; + esac + + # Update display + echo -ne "\rStarted: $STARTED | Progress: $PROGRESS | Completed: $COMPLETED" + fi + done +else + echo "Unknown format: $FORMAT" + echo "Available formats: raw, json, pretty, stats" + exit 1 +fi diff --git a/scripts/mercure-test.sh b/scripts/mercure-test.sh new file mode 100755 index 0000000..df3d3bc --- /dev/null +++ b/scripts/mercure-test.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# End-to-end test of Mercure real-time events + +ITERATIONS=${1:-3} +TEST=${2:-"Loop"} +VERSION=${3:-"php84"} + +echo "╔════════════════════════════════════════════════════════════════╗" +echo "║ Mercure End-to-End Test ║" +echo "╚════════════════════════════════════════════════════════════════╝" +echo "" +echo "Configuration:" +echo " Test: $TEST" +echo " PHP Version: $VERSION" +echo " Iterations: $ITERATIONS" +echo "" + +# Create temp file for events +EVENTS_FILE="/tmp/mercure_test_$$" + +echo "Starting event listener in background..." +timeout 60 curl -N "http://localhost:3000/.well-known/mercure?topic=benchmark/progress" > "$EVENTS_FILE" 2>&1 & +CURL_PID=$! + +# Give curl time to connect +sleep 2 + +echo "Running benchmark..." +docker-compose exec -T main php bin/console benchmark:run \ + --test="$TEST" \ + --php-version="$VERSION" \ + --iterations="$ITERATIONS" + +BENCHMARK_EXIT=$? + +# Wait for events to be captured +echo "Waiting for events to be captured..." +sleep 3 + +# Kill curl +kill $CURL_PID 2>/dev/null + +echo "" +echo "════════════════════════════════════════════════════════════════" +echo "Results:" +echo "════════════════════════════════════════════════════════════════" +echo "" + +# Parse events +STARTED=$(grep -c '"benchmark.started"' "$EVENTS_FILE" 2>/dev/null || echo "0") +PROGRESS=$(grep -c '"benchmark.progress"' "$EVENTS_FILE" 2>/dev/null || echo "0") +COMPLETED=$(grep -c '"benchmark.completed"' "$EVENTS_FILE" 2>/dev/null || echo "0") +TOTAL_EVENTS=$(grep -c '^data:' "$EVENTS_FILE" 2>/dev/null || echo "0") + +echo "Events received:" +echo " 📨 Total events: $TOTAL_EVENTS" +echo " 🚀 Started: $STARTED" +echo " 🔄 Progress: $PROGRESS" +echo " ✅ Completed: $COMPLETED" +echo "" + +# Expected counts +EXPECTED_STARTED=1 +EXPECTED_PROGRESS=$ITERATIONS +EXPECTED_COMPLETED=1 +EXPECTED_TOTAL=$((EXPECTED_STARTED + EXPECTED_PROGRESS + EXPECTED_COMPLETED)) + +echo "Expected:" +echo " 📨 Total events: $EXPECTED_TOTAL" +echo " 🚀 Started: $EXPECTED_STARTED" +echo " 🔄 Progress: $EXPECTED_PROGRESS" +echo " ✅ Completed: $EXPECTED_COMPLETED" +echo "" + +# Validation +ALL_PASSED=true + +if [ "$BENCHMARK_EXIT" -ne 0 ]; then + echo "❌ Benchmark execution failed (exit code: $BENCHMARK_EXIT)" + ALL_PASSED=false +fi + +if [ "$STARTED" -ne "$EXPECTED_STARTED" ]; then + echo "❌ Started events mismatch (expected $EXPECTED_STARTED, got $STARTED)" + ALL_PASSED=false +fi + +if [ "$PROGRESS" -ne "$EXPECTED_PROGRESS" ]; then + echo "❌ Progress events mismatch (expected $EXPECTED_PROGRESS, got $PROGRESS)" + ALL_PASSED=false +fi + +if [ "$COMPLETED" -ne "$EXPECTED_COMPLETED" ]; then + echo "❌ Completed events mismatch (expected $EXPECTED_COMPLETED, got $COMPLETED)" + ALL_PASSED=false +fi + +echo "" +echo "════════════════════════════════════════════════════════════════" + +if [ "$ALL_PASSED" = true ]; then + echo "🎉 All tests passed! Mercure real-time events are working correctly." + echo "" + echo "Sample event:" + grep -m 1 '^data:' "$EVENTS_FILE" | sed 's/^data: //' | python3 -m json.tool 2>/dev/null || \ + grep -m 1 '^data:' "$EVENTS_FILE" + + rm -f "$EVENTS_FILE" + exit 0 +else + echo "❌ Some tests failed." + echo "" + echo "Debug information saved to: $EVENTS_FILE" + echo "View with: cat $EVENTS_FILE" + exit 1 +fi diff --git a/scripts/mercure-verify.sh b/scripts/mercure-verify.sh new file mode 100755 index 0000000..f7c8e74 --- /dev/null +++ b/scripts/mercure-verify.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# Mercure Verification Script +# Checks that Mercure is properly configured and working + +set -e + +echo "╔════════════════════════════════════════════════════════════════╗" +echo "║ Mercure Configuration Verification ║" +echo "╚════════════════════════════════════════════════════════════════╝" +echo "" + +SUCCESS=0 +FAILED=0 + +check_passed() { + echo " ✅ $1" + SUCCESS=$((SUCCESS + 1)) +} + +check_failed() { + echo " ❌ $1" + FAILED=$((FAILED + 1)) +} + +# 1. Check Mercure container +echo "1. Checking Mercure container status..." +if docker-compose ps mercure | grep -q "Up"; then + check_passed "Mercure container is running" +else + check_failed "Mercure container is not running" + echo " Fix: docker-compose up -d mercure" +fi +echo "" + +# 2. Check Mercure accessibility +echo "2. Checking Mercure HTTP accessibility..." +RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/.well-known/mercure) +if [ "$RESPONSE" = "400" ]; then + check_passed "Mercure is accessible (HTTP 400 expected)" +else + check_failed "Mercure returned HTTP $RESPONSE (expected 400)" +fi +echo "" + +# 3. Check port binding +echo "3. Checking port binding..." +if docker-compose ps mercure | grep -q "3000->80"; then + check_passed "Port 3000 is properly mapped" +else + check_failed "Port 3000 is not mapped correctly" +fi +echo "" + +# 4. Check environment variables +echo "4. Checking Symfony environment variables..." + +MERCURE_URL=$(docker-compose exec -T main env 2>/dev/null | grep MERCURE_URL= | cut -d= -f2-) +if [ -n "$MERCURE_URL" ]; then + check_passed "MERCURE_URL is set: $MERCURE_URL" +else + check_failed "MERCURE_URL is not set" +fi + +MERCURE_PUBLIC_URL=$(docker-compose exec -T main env 2>/dev/null | grep MERCURE_PUBLIC_URL= | cut -d= -f2-) +if [ -n "$MERCURE_PUBLIC_URL" ]; then + check_passed "MERCURE_PUBLIC_URL is set: $MERCURE_PUBLIC_URL" +else + check_failed "MERCURE_PUBLIC_URL is not set" +fi + +MERCURE_JWT_SECRET=$(docker-compose exec -T main env 2>/dev/null | grep MERCURE_JWT_SECRET= | cut -d= -f2-) +if [ -n "$MERCURE_JWT_SECRET" ]; then + check_passed "MERCURE_JWT_SECRET is set" +else + check_failed "MERCURE_JWT_SECRET is not set" +fi +echo "" + +# 5. Check Mercure bundle configuration +echo "5. Checking Mercure bundle configuration..." +if [ -f "config/packages/mercure.yaml" ]; then + check_passed "Mercure bundle configuration file exists" +else + check_failed "Mercure bundle configuration file not found" +fi +echo "" + +# 6. Check event subscriber registration +echo "6. Checking event subscriber registration..." +if docker-compose exec -T main php bin/console debug:event-dispatcher 2>/dev/null | grep -q "BenchmarkProgressSubscriber"; then + check_passed "BenchmarkProgressSubscriber is registered" + + # Count how many events it handles + EVENT_COUNT=$(docker-compose exec -T main php bin/console debug:event-dispatcher 2>/dev/null | grep -c "BenchmarkProgressSubscriber" || echo "0") + if [ "$EVENT_COUNT" -ge 3 ]; then + check_passed "Handling $EVENT_COUNT events (expected 3)" + else + check_failed "Only handling $EVENT_COUNT events (expected 3)" + fi +else + check_failed "BenchmarkProgressSubscriber is not registered" +fi +echo "" + +# 7. Check Mercure logs for errors +echo "7. Checking Mercure logs for recent errors..." +ERROR_COUNT=$(docker-compose logs --tail=50 mercure 2>/dev/null | grep -ic "error" 2>/dev/null || echo "0") +# Remove any whitespace and newlines +ERROR_COUNT=$(echo "$ERROR_COUNT" | tr -d '\n\r ' | head -c 10) +if [ -z "$ERROR_COUNT" ]; then + ERROR_COUNT=0 +fi +if [ "$ERROR_COUNT" -eq 0 ] 2>/dev/null; then + check_passed "No errors in recent Mercure logs" +else + check_failed "Found $ERROR_COUNT error(s) in recent Mercure logs" + echo " Run: docker-compose logs mercure" +fi +echo "" + +# 8. Check CORS configuration +echo "8. Checking CORS configuration..." +if docker-compose config | grep -q "cors_origins"; then + check_passed "CORS is configured in docker-compose.yml" +else + check_failed "CORS configuration not found" +fi +echo "" + +# Summary +echo "════════════════════════════════════════════════════════════════" +echo "Summary: $SUCCESS passed, $FAILED failed" +echo "════════════════════════════════════════════════════════════════" +echo "" + +if [ "$FAILED" -eq 0 ]; then + echo "🎉 All checks passed! Mercure is working correctly." + echo "" + echo "To test real-time events:" + echo " Terminal 1: ./scripts/mercure-listen.sh" + echo " Terminal 2: make run test=Loop iterations=5 version=php84" + exit 0 +else + echo "⚠️ Some checks failed. Please fix the issues above." + exit 1 +fi diff --git a/src/Application/Dashboard/DTO/BenchmarkData.php b/src/Application/Dashboard/DTO/BenchmarkData.php new file mode 100644 index 0000000..d549fd0 --- /dev/null +++ b/src/Application/Dashboard/DTO/BenchmarkData.php @@ -0,0 +1,21 @@ + $phpVersions Statistics indexed by PHP version + */ + public function __construct( + public string $benchmarkId, + public string $benchmarkName, + public array $phpVersions, + ) { + } +} diff --git a/src/Application/Dashboard/DTO/BenchmarkStatisticsData.php b/src/Application/Dashboard/DTO/BenchmarkStatisticsData.php new file mode 100644 index 0000000..04bd669 --- /dev/null +++ b/src/Application/Dashboard/DTO/BenchmarkStatisticsData.php @@ -0,0 +1,68 @@ +benchmarkId, + benchmarkName: $benchmarkStatistics->benchmarkName, + phpVersion: $benchmarkStatistics->phpVersion, + count: $benchmarkStatistics->executionCount, + avg: $benchmarkStatistics->averageExecutionTime, + percentiles: $benchmarkStatistics->percentiles, + memoryUsed: $benchmarkStatistics->averageMemoryUsed, + memoryPeak: $benchmarkStatistics->peakMemoryUsed, + ); + } + + public function getP50(): float + { + return $this->percentiles->p50; + } + + public function getP80(): float + { + return $this->percentiles->p80; + } + + public function getP90(): float + { + return $this->percentiles->p90; + } + + public function getP95(): float + { + return $this->percentiles->p95; + } + + public function getP99(): float + { + return $this->percentiles->p99; + } +} diff --git a/src/Application/Dashboard/UseCase/GetBenchmarkStatistics.php b/src/Application/Dashboard/UseCase/GetBenchmarkStatistics.php new file mode 100644 index 0000000..a9660b4 --- /dev/null +++ b/src/Application/Dashboard/UseCase/GetBenchmarkStatistics.php @@ -0,0 +1,39 @@ +pulseRepository->findMetricsByBenchmark($benchmarkId, $benchmarkName); + + $phpVersionStats = []; + foreach ($metrics as $metric) { + $statistics = $this->statisticsCalculator->calculate($metric); + $phpVersionStats[$metric->phpVersion] = BenchmarkStatisticsData::fromDomain($statistics); + } + + return new BenchmarkData( + benchmarkId: $benchmarkId, + benchmarkName: $benchmarkName, + phpVersions: $phpVersionStats, + ); + } +} diff --git a/src/Application/Service/ChartBuilder.php b/src/Application/Service/ChartBuilder.php deleted file mode 100644 index b8c46a4..0000000 --- a/src/Application/Service/ChartBuilder.php +++ /dev/null @@ -1,76 +0,0 @@ -chartBuilder->createChart(Chart::TYPE_BAR); - - // Préparation des données en conservant l'ordre des versions - $p50Data = []; - $p90Data = []; - $avgData = []; - - foreach ($allPhpVersions as $version) { - $p50Data[] = $benchmark['phpVersions'][$version]['p50'] ?? null; - $p90Data[] = $benchmark['phpVersions'][$version]['p90'] ?? null; - $avgData[] = $benchmark['phpVersions'][$version]['avg'] ?? null; - } - - $chart->setData([ - 'labels' => array_map(fn($version) => 'PHP ' . str_replace('php', '', $version), $allPhpVersions), - 'datasets' => [ - [ - 'label' => 'p50 (ms)', - 'data' => $p50Data, - 'backgroundColor' => 'rgba(54, 162, 235, 0.5)', - 'borderColor' => 'rgba(54, 162, 235, 1)', - 'borderWidth' => 1 - ], - [ - 'label' => 'p90 (ms)', - 'data' => $p90Data, - 'backgroundColor' => 'rgba(255, 159, 64, 0.5)', - 'borderColor' => 'rgba(255, 159, 64, 1)', - 'borderWidth' => 1 - ], - [ - 'label' => 'Moyenne (ms)', - 'data' => $avgData, - 'backgroundColor' => 'rgba(75, 192, 192, 0.5)', - 'borderColor' => 'rgba(75, 192, 192, 1)', - 'borderWidth' => 1 - ] - ] - ]); - - $chart->setOptions([ - 'responsive' => true, - 'scales' => [ - 'y' => [ - 'beginAtZero' => true, - 'title' => [ - 'display' => true, - 'text' => 'Temps d\'exécution (ms)' - ] - ] - ], - 'plugins' => [ - 'legend' => [ - 'position' => 'top' - ] - ] - ]); - - return $chart; - } -} diff --git a/src/Application/UseCase/AsyncBenchmarkRunner.php b/src/Application/UseCase/AsyncBenchmarkRunner.php index d42500a..939c479 100644 --- a/src/Application/UseCase/AsyncBenchmarkRunner.php +++ b/src/Application/UseCase/AsyncBenchmarkRunner.php @@ -4,34 +4,74 @@ namespace Jblairy\PhpBenchmark\Application\UseCase; +use Jblairy\PhpBenchmark\Domain\Benchmark\Event\BenchmarkCompleted; +use Jblairy\PhpBenchmark\Domain\Benchmark\Event\BenchmarkProgress; +use Jblairy\PhpBenchmark\Domain\Benchmark\Event\BenchmarkStarted; use Jblairy\PhpBenchmark\Domain\Benchmark\Model\BenchmarkConfiguration; +use Jblairy\PhpBenchmark\Domain\Benchmark\Port\AsyncExecutorPort; use Jblairy\PhpBenchmark\Domain\Benchmark\Port\BenchmarkExecutorPort; +use Jblairy\PhpBenchmark\Domain\Benchmark\Port\EventDispatcherPort; use Jblairy\PhpBenchmark\Domain\Benchmark\Port\ResultPersisterPort; -use Spatie\Async\Pool; -final class AsyncBenchmarkRunner +final readonly class AsyncBenchmarkRunner { - private const DEFAULT_CONCURRENCY = 100; - public function __construct( - private readonly BenchmarkExecutorPort $executor, - private readonly ResultPersisterPort $persister, - private readonly int $concurrency = self::DEFAULT_CONCURRENCY, + private BenchmarkExecutorPort $benchmarkExecutorPort, + private ResultPersisterPort $resultPersisterPort, + private EventDispatcherPort $eventDispatcher, + private AsyncExecutorPort $asyncExecutor, ) { } - public function run(BenchmarkConfiguration $configuration): void + public function run(BenchmarkConfiguration $benchmarkConfiguration): void { - $pool = Pool::create()->concurrency($this->concurrency); - - for ($i = 0; $i < $configuration->iterations; ++$i) { - $pool->add(function () use ($configuration) { - return $this->executor->execute($configuration); - })->then(function ($result) use ($configuration) { - $this->persister->persist($configuration, $result); - }); + $benchmarkId = $benchmarkConfiguration->benchmark::class; + $benchmarkName = $benchmarkConfiguration->getBenchmarkName(); + $phpVersion = $benchmarkConfiguration->phpVersion->value; + $totalIterations = $benchmarkConfiguration->iterations; + + $this->eventDispatcher->dispatch( + new BenchmarkStarted( + benchmarkId: $benchmarkId, + benchmarkName: $benchmarkName, + phpVersion: $phpVersion, + totalIterations: $totalIterations, + ), + ); + + $completedIterations = 0; + $results = []; + + for ($i = 0; $i < $benchmarkConfiguration->iterations; ++$i) { + $this->asyncExecutor->addTask( + task: fn (): \Jblairy\PhpBenchmark\Domain\Benchmark\Model\BenchmarkResult => $this->benchmarkExecutorPort->execute($benchmarkConfiguration), + onSuccess: function (\Jblairy\PhpBenchmark\Domain\Benchmark\Model\BenchmarkResult $result) use ($benchmarkConfiguration, &$completedIterations, &$results, $benchmarkId, $benchmarkName, $phpVersion, $totalIterations): void { + $this->resultPersisterPort->persist($benchmarkConfiguration, $result); + $results[] = $result; + ++$completedIterations; + + $this->eventDispatcher->dispatch( + new BenchmarkProgress( + benchmarkId: $benchmarkId, + benchmarkName: $benchmarkName, + phpVersion: $phpVersion, + currentIteration: $completedIterations, + totalIterations: $totalIterations, + ), + ); + }, + ); } - $pool->wait(); + $this->asyncExecutor->wait(); + + $this->eventDispatcher->dispatch( + new BenchmarkCompleted( + benchmarkId: $benchmarkId, + benchmarkName: $benchmarkName, + phpVersion: $phpVersion, + totalIterations: $totalIterations, + ), + ); } } diff --git a/src/Application/UseCase/BenchmarkOrchestrator.php b/src/Application/UseCase/BenchmarkOrchestrator.php index 6307ce7..c9d39a0 100644 --- a/src/Application/UseCase/BenchmarkOrchestrator.php +++ b/src/Application/UseCase/BenchmarkOrchestrator.php @@ -5,25 +5,29 @@ namespace Jblairy\PhpBenchmark\Application\UseCase; use Jblairy\PhpBenchmark\Domain\Benchmark\Contract\Benchmark; +use Jblairy\PhpBenchmark\Domain\Benchmark\Exception\ReflexionMethodNotFound; use Jblairy\PhpBenchmark\Domain\Benchmark\Model\BenchmarkConfiguration; use Jblairy\PhpBenchmark\Domain\Benchmark\Port\BenchmarkRepositoryPort; use Jblairy\PhpBenchmark\Domain\Benchmark\Port\CodeExtractorPort; -use Jblairy\PhpBenchmark\Domain\Benchmark\Exception\ReflexionMethodNotFound; use Jblairy\PhpBenchmark\Domain\PhpVersion\Enum\PhpVersion; -final class BenchmarkOrchestrator +final readonly class BenchmarkOrchestrator { public function __construct( - private readonly AsyncBenchmarkRunner $runner, - private readonly CodeExtractorPort $codeExtractor, + private AsyncBenchmarkRunner $asyncBenchmarkRunner, + private CodeExtractorPort $codeExtractorPort, ) { } - public function executeSingle(BenchmarkConfiguration $configuration): void + public function executeSingle(BenchmarkConfiguration $benchmarkConfiguration): void { - $this->runner->run($configuration); + $this->asyncBenchmarkRunner->run($benchmarkConfiguration); } + /** + * @param Benchmark[] $benchmarks + * @param PhpVersion[] $phpVersions + */ public function executeMultiple(array $benchmarks, array $phpVersions, int $iterations): void { foreach ($benchmarks as $benchmark) { @@ -38,27 +42,28 @@ public function executeMultiple(array $benchmarks, array $phpVersions, int $iter iterations: $iterations, ); - $this->runner->run($configuration); + $this->asyncBenchmarkRunner->run($configuration); } } } + public function executeAll(BenchmarkRepositoryPort $benchmarkRepositoryPort, int $iterations): void + { + $this->executeMultiple( + benchmarks: $benchmarkRepositoryPort->getAllBenchmarks(), + phpVersions: PhpVersion::cases(), + iterations: $iterations, + ); + } + private function benchmarkSupportsVersion(Benchmark $benchmark, PhpVersion $phpVersion): bool { try { - $this->codeExtractor->extractCode($benchmark, $phpVersion); + $this->codeExtractorPort->extractCode($benchmark, $phpVersion); + return true; } catch (ReflexionMethodNotFound) { return false; } } - - public function executeAll(BenchmarkRepositoryPort $registry, int $iterations): void - { - $this->executeMultiple( - benchmarks: $registry->getAllBenchmarks(), - phpVersions: PhpVersion::cases(), - iterations: $iterations - ); - } } diff --git a/src/Domain/Benchmark/Contract/AbstractBenchmark.php b/src/Domain/Benchmark/Contract/AbstractBenchmark.php deleted file mode 100644 index e0a28a9..0000000 --- a/src/Domain/Benchmark/Contract/AbstractBenchmark.php +++ /dev/null @@ -1,43 +0,0 @@ -getReflexionMethod($phpVersion); - $fileName = (string) $reflection->getFileName(); - $startLine = (int) $reflection->getStartLine() + 1; // TODO make it better - $endLine = (int) $reflection->getEndLine() - 1; // TODO make it better - $code = (array) file($fileName); - - $lines = array_slice($code, $startLine, $endLine - $startLine); - $script = implode('', $lines); - - return str_replace(['<<getMethods() as $method) { - foreach ($method->getAttributes() as $attribute) { - if (All::class === $attribute->getName() || str_ends_with(mb_strtolower($attribute->getName()), $phpVersion->value)) { - return $method; - } - } - } - - throw new ReflexionMethodNotFound($this::class, $phpVersion->value); - } -} diff --git a/src/Domain/Benchmark/Contract/Benchmark.php b/src/Domain/Benchmark/Contract/Benchmark.php index 3682e60..bdfc8a7 100644 --- a/src/Domain/Benchmark/Contract/Benchmark.php +++ b/src/Domain/Benchmark/Contract/Benchmark.php @@ -5,9 +5,13 @@ namespace Jblairy\PhpBenchmark\Domain\Benchmark\Contract; use Jblairy\PhpBenchmark\Domain\PhpVersion\Enum\PhpVersion; -use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag; -#[AutoconfigureTag(Benchmark::class)] +/** + * Core Domain interface for benchmark implementations. + * + * This interface represents the contract that all benchmarks must fulfill. + * It is intentionally framework-agnostic to keep the Domain pure. + */ interface Benchmark { public function getMethodBody(PhpVersion $phpVersion): string; diff --git a/src/Domain/Benchmark/Event/BenchmarkCompleted.php b/src/Domain/Benchmark/Event/BenchmarkCompleted.php new file mode 100644 index 0000000..67d2d29 --- /dev/null +++ b/src/Domain/Benchmark/Event/BenchmarkCompleted.php @@ -0,0 +1,34 @@ + + */ + public function toArray(): array + { + return [ + 'type' => 'benchmark.completed', + 'benchmarkId' => $this->benchmarkId, + 'benchmarkName' => $this->benchmarkName, + 'phpVersion' => $this->phpVersion, + 'totalIterations' => $this->totalIterations, + 'timestamp' => time(), + ]; + } +} diff --git a/src/Domain/Benchmark/Event/BenchmarkProgress.php b/src/Domain/Benchmark/Event/BenchmarkProgress.php new file mode 100644 index 0000000..83f3258 --- /dev/null +++ b/src/Domain/Benchmark/Event/BenchmarkProgress.php @@ -0,0 +1,37 @@ + + */ + public function toArray(): array + { + return [ + 'type' => 'benchmark.progress', + 'benchmarkId' => $this->benchmarkId, + 'benchmarkName' => $this->benchmarkName, + 'phpVersion' => $this->phpVersion, + 'currentIteration' => $this->currentIteration, + 'totalIterations' => $this->totalIterations, + 'progress' => 0 < $this->totalIterations ? (int) (($this->currentIteration / $this->totalIterations) * 100) : 0, + 'timestamp' => time(), + ]; + } +} diff --git a/src/Domain/Benchmark/Event/BenchmarkStarted.php b/src/Domain/Benchmark/Event/BenchmarkStarted.php new file mode 100644 index 0000000..d217a16 --- /dev/null +++ b/src/Domain/Benchmark/Event/BenchmarkStarted.php @@ -0,0 +1,34 @@ + + */ + public function toArray(): array + { + return [ + 'type' => 'benchmark.started', + 'benchmarkId' => $this->benchmarkId, + 'benchmarkName' => $this->benchmarkName, + 'phpVersion' => $this->phpVersion, + 'totalIterations' => $this->totalIterations, + 'timestamp' => time(), + ]; + } +} diff --git a/src/Domain/Benchmark/Exception/BenchmarkNotFound.php b/src/Domain/Benchmark/Exception/BenchmarkNotFound.php index fcd4841..50656ac 100644 --- a/src/Domain/Benchmark/Exception/BenchmarkNotFound.php +++ b/src/Domain/Benchmark/Exception/BenchmarkNotFound.php @@ -10,6 +10,6 @@ final class BenchmarkNotFound extends RuntimeException { public function __construct(string $name) { - parent::__construct("Benchmark {$name} not found"); + parent::__construct(sprintf('Benchmark %s not found', $name)); } } diff --git a/src/Domain/Benchmark/Exception/ReflexionMethodNotFound.php b/src/Domain/Benchmark/Exception/ReflexionMethodNotFound.php index a1c403c..10f2014 100644 --- a/src/Domain/Benchmark/Exception/ReflexionMethodNotFound.php +++ b/src/Domain/Benchmark/Exception/ReflexionMethodNotFound.php @@ -10,6 +10,6 @@ final class ReflexionMethodNotFound extends RuntimeException { public function __construct(string $benchmarkName, string $version) { - parent::__construct("Reflexion method not found for benchmark {$benchmarkName} and php version {$version}"); + parent::__construct(sprintf('Reflexion method not found for benchmark %s and php version %s', $benchmarkName, $version)); } } diff --git a/src/Domain/Benchmark/Model/BenchmarkConfiguration.php b/src/Domain/Benchmark/Model/BenchmarkConfiguration.php index ec33271..bcdf6b3 100644 --- a/src/Domain/Benchmark/Model/BenchmarkConfiguration.php +++ b/src/Domain/Benchmark/Model/BenchmarkConfiguration.php @@ -4,6 +4,7 @@ namespace Jblairy\PhpBenchmark\Domain\Benchmark\Model; +use InvalidArgumentException; use Jblairy\PhpBenchmark\Domain\Benchmark\Contract\Benchmark; use Jblairy\PhpBenchmark\Domain\PhpVersion\Enum\PhpVersion; @@ -14,8 +15,8 @@ public function __construct( public PhpVersion $phpVersion, public int $iterations, ) { - if ($this->iterations <= 0) { - throw new \InvalidArgumentException('Iterations must be greater than 0'); + if (0 >= $this->iterations) { + throw new InvalidArgumentException('Iterations must be greater than 0'); } } diff --git a/src/Domain/Benchmark/Model/BenchmarkResult.php b/src/Domain/Benchmark/Model/BenchmarkResult.php index dcbbfa5..91f3ee3 100644 --- a/src/Domain/Benchmark/Model/BenchmarkResult.php +++ b/src/Domain/Benchmark/Model/BenchmarkResult.php @@ -13,12 +13,19 @@ public function __construct( ) { } + /** + * @param array $data + */ public static function fromArray(array $data): self { + $executionTime = $data['execution_time_ms'] ?? 0; + $memoryUsed = $data['memory_used_bytes'] ?? 0; + $memoryPeak = $data['memory_peak_bytes'] ?? 0; + return new self( - executionTimeMs: (float) ($data['execution_time_ms'] ?? 0), - memoryUsedBytes: (float) ($data['memory_used_bytes'] ?? 0), - memoryPeakBytes: (float) ($data['memory_peak_bytes'] ?? 0), + executionTimeMs: is_numeric($executionTime) ? (float) $executionTime : 0.0, + memoryUsedBytes: is_numeric($memoryUsed) ? (float) $memoryUsed : 0.0, + memoryPeakBytes: is_numeric($memoryPeak) ? (float) $memoryPeak : 0.0, ); } } diff --git a/src/Domain/Benchmark/Port/AsyncExecutorPort.php b/src/Domain/Benchmark/Port/AsyncExecutorPort.php new file mode 100644 index 0000000..20cee55 --- /dev/null +++ b/src/Domain/Benchmark/Port/AsyncExecutorPort.php @@ -0,0 +1,29 @@ +codeExtractor->extractCode( - $configuration->benchmark, - $configuration->phpVersion + $code = $this->codeExtractorPort->extractCode( + $benchmarkConfiguration->benchmark, + $benchmarkConfiguration->phpVersion, ); - $script = $this->scriptBuilder->build($code); + $script = $this->scriptBuilderPort->build($code); - $context = new ExecutionContext( - phpVersion: $configuration->phpVersion, + $executionContext = new ExecutionContext( + phpVersion: $benchmarkConfiguration->phpVersion, scriptContent: $script, - benchmarkClassName: $configuration->benchmark::class, + benchmarkClassName: $benchmarkConfiguration->benchmark::class, ); - return $this->scriptExecutor->executeScript($context); + return $this->scriptExecutorPort->executeScript($executionContext); } } diff --git a/src/Domain/Benchmark/Test/AdvancedArrays/ColumnWithArrayColumn.php b/src/Domain/Benchmark/Test/AdvancedArrays/ColumnWithArrayColumn.php deleted file mode 100644 index e223c08..0000000 --- a/src/Domain/Benchmark/Test/AdvancedArrays/ColumnWithArrayColumn.php +++ /dev/null @@ -1,22 +0,0 @@ - $i, 'name' => 'User' . $i, 'email' => 'user' . $i . '@test.com']; - } - - $result = array_column($data, 'name'); - } -} diff --git a/src/Domain/Benchmark/Test/AdvancedArrays/ColumnWithArrayMap.php b/src/Domain/Benchmark/Test/AdvancedArrays/ColumnWithArrayMap.php deleted file mode 100644 index a4d8b03..0000000 --- a/src/Domain/Benchmark/Test/AdvancedArrays/ColumnWithArrayMap.php +++ /dev/null @@ -1,24 +0,0 @@ - $i, 'name' => 'User' . $i, 'email' => 'user' . $i . '@test.com']; - } - - $result = array_map(function ($item) { - return $item['name']; - }, $data); - } -} diff --git a/src/Domain/Benchmark/Test/AdvancedArrays/FilterWithArrayFilter.php b/src/Domain/Benchmark/Test/AdvancedArrays/FilterWithArrayFilter.php deleted file mode 100644 index e6287b3..0000000 --- a/src/Domain/Benchmark/Test/AdvancedArrays/FilterWithArrayFilter.php +++ /dev/null @@ -1,21 +0,0 @@ - $x * 2)($i); - } - } -} diff --git a/src/Domain/Benchmark/Test/Callbacks/CallWithCallUserFunc.php b/src/Domain/Benchmark/Test/Callbacks/CallWithCallUserFunc.php deleted file mode 100644 index 4f5c715..0000000 --- a/src/Domain/Benchmark/Test/Callbacks/CallWithCallUserFunc.php +++ /dev/null @@ -1,23 +0,0 @@ - 'John Doe', - 'email' => 'john@example.com', - 'age' => 30, - 'items' => range(1, 100), - ]; - - for ($i = 0; $i < 10000; ++$i) { - $result = json_encode($data); - } - } -} diff --git a/src/Domain/Benchmark/Test/JsonOperations/EncodeWithSerialize.php b/src/Domain/Benchmark/Test/JsonOperations/EncodeWithSerialize.php deleted file mode 100644 index 61c4c26..0000000 --- a/src/Domain/Benchmark/Test/JsonOperations/EncodeWithSerialize.php +++ /dev/null @@ -1,26 +0,0 @@ - 'John Doe', - 'email' => 'john@example.com', - 'age' => 30, - 'items' => range(1, 100), - ]; - - for ($i = 0; $i < 10000; ++$i) { - $result = serialize($data); - } - } -} diff --git a/src/Domain/Benchmark/Test/Loop.php b/src/Domain/Benchmark/Test/Loop.php deleted file mode 100644 index 74899e5..0000000 --- a/src/Domain/Benchmark/Test/Loop.php +++ /dev/null @@ -1,21 +0,0 @@ - $i; ++$i) { - $x[] = $i * 2; - } - } -} diff --git a/src/Domain/Benchmark/Test/MatchExpression/CompareWithMatch.php b/src/Domain/Benchmark/Test/MatchExpression/CompareWithMatch.php deleted file mode 100644 index e837602..0000000 --- a/src/Domain/Benchmark/Test/MatchExpression/CompareWithMatch.php +++ /dev/null @@ -1,36 +0,0 @@ - 'zero', - 1 => 'one', - 2 => 'two', - 3 => 'three', - default => 'other', - }; - } - } -} diff --git a/src/Domain/Benchmark/Test/MatchExpression/CompareWithSwitch.php b/src/Domain/Benchmark/Test/MatchExpression/CompareWithSwitch.php deleted file mode 100644 index d6fc7ae..0000000 --- a/src/Domain/Benchmark/Test/MatchExpression/CompareWithSwitch.php +++ /dev/null @@ -1,35 +0,0 @@ - 'value1', 'key2' => null, 'key3' => 'value3']; - - for ($i = 0; $i < 100000; ++$i) { - $result = isset($data['key2']) ? $data['key2'] : 'default'; - } - } -} diff --git a/src/Domain/Benchmark/Test/NullCoalescing/CheckWithNullCoalescing.php b/src/Domain/Benchmark/Test/NullCoalescing/CheckWithNullCoalescing.php deleted file mode 100644 index 35bbd1e..0000000 --- a/src/Domain/Benchmark/Test/NullCoalescing/CheckWithNullCoalescing.php +++ /dev/null @@ -1,41 +0,0 @@ - 'value1', 'key2' => null, 'key3' => 'value3']; - - for ($i = 0; $i < 100000; ++$i) { - $result = $data['key2'] ?? 'default'; - } - } -} diff --git a/src/Domain/Benchmark/Test/Numeric/AbsWithAbs.php b/src/Domain/Benchmark/Test/Numeric/AbsWithAbs.php deleted file mode 100644 index 855e47b..0000000 --- a/src/Domain/Benchmark/Test/Numeric/AbsWithAbs.php +++ /dev/null @@ -1,19 +0,0 @@ -value = 123; - $original->name = 'test'; - - for ($i = 0; $i < 100000; ++$i) { - $copy = clone $original; - } - } -} diff --git a/src/Domain/Benchmark/Test/ObjectCloning/CloneWithNewInstance.php b/src/Domain/Benchmark/Test/ObjectCloning/CloneWithNewInstance.php deleted file mode 100644 index 3990959..0000000 --- a/src/Domain/Benchmark/Test/ObjectCloning/CloneWithNewInstance.php +++ /dev/null @@ -1,22 +0,0 @@ -value = 123; - $copy->name = 'test'; - } - } -} diff --git a/src/Domain/Benchmark/Test/ObjectCloning/CloneWithSerialize.php b/src/Domain/Benchmark/Test/ObjectCloning/CloneWithSerialize.php deleted file mode 100644 index d092199..0000000 --- a/src/Domain/Benchmark/Test/ObjectCloning/CloneWithSerialize.php +++ /dev/null @@ -1,24 +0,0 @@ -value = 123; - $original->name = 'test'; - - for ($i = 0; $i < 100000; ++$i) { - $copy = unserialize(serialize($original)); - } - } -} diff --git a/src/Domain/Benchmark/Test/ObjectOperations/CheckMethodWithIsCallable.php b/src/Domain/Benchmark/Test/ObjectOperations/CheckMethodWithIsCallable.php deleted file mode 100644 index 5347d73..0000000 --- a/src/Domain/Benchmark/Test/ObjectOperations/CheckMethodWithIsCallable.php +++ /dev/null @@ -1,28 +0,0 @@ -value = 123; - - for ($i = 0; $i < 100000; ++$i) { - $result = isset($obj->value); - } - } -} diff --git a/src/Domain/Benchmark/Test/ObjectOperations/CheckWithPropertyExists.php b/src/Domain/Benchmark/Test/ObjectOperations/CheckWithPropertyExists.php deleted file mode 100644 index 061bbe5..0000000 --- a/src/Domain/Benchmark/Test/ObjectOperations/CheckWithPropertyExists.php +++ /dev/null @@ -1,23 +0,0 @@ -value = 123; - - for ($i = 0; $i < 100000; ++$i) { - $result = property_exists($obj, 'value'); - } - } -} diff --git a/src/Domain/Benchmark/Test/Php8Features/NullsafeWithIssetCheck.php b/src/Domain/Benchmark/Test/Php8Features/NullsafeWithIssetCheck.php deleted file mode 100644 index c666476..0000000 --- a/src/Domain/Benchmark/Test/Php8Features/NullsafeWithIssetCheck.php +++ /dev/null @@ -1,26 +0,0 @@ -address) ? \$user->address : null; - } - PHP; - } -} diff --git a/src/Domain/Benchmark/Test/Php8Features/NullsafeWithNullsafe.php b/src/Domain/Benchmark/Test/Php8Features/NullsafeWithNullsafe.php deleted file mode 100644 index 4b9c0a1..0000000 --- a/src/Domain/Benchmark/Test/Php8Features/NullsafeWithNullsafe.php +++ /dev/null @@ -1,36 +0,0 @@ -address; - } - PHP; - } -} diff --git a/src/Domain/Benchmark/Test/Php8Features/StrEndsWithFunction.php b/src/Domain/Benchmark/Test/Php8Features/StrEndsWithFunction.php deleted file mode 100644 index d142998..0000000 --- a/src/Domain/Benchmark/Test/Php8Features/StrEndsWithFunction.php +++ /dev/null @@ -1,31 +0,0 @@ - strtoupper(...) - |> str_shuffle(...) - |> trim(...); - PHP; - } - - #[Php56] - #[Php70] - #[Php71] - #[Php72] - #[Php73] - #[Php74] - #[Php80] - #[Php81] - #[Php82] - #[Php83] - #[Php84] - public function executeWithPhp8AndOlder(): void - { - << $b; - }); - } - } -} diff --git a/src/Domain/Benchmark/Test/StaticVsInstance/AccessInstanceProperty.php b/src/Domain/Benchmark/Test/StaticVsInstance/AccessInstanceProperty.php deleted file mode 100644 index 27ff08d..0000000 --- a/src/Domain/Benchmark/Test/StaticVsInstance/AccessInstanceProperty.php +++ /dev/null @@ -1,26 +0,0 @@ -value; - } - PHP; - } -} diff --git a/src/Domain/Benchmark/Test/StaticVsInstance/AccessStaticProperty.php b/src/Domain/Benchmark/Test/StaticVsInstance/AccessStaticProperty.php deleted file mode 100644 index a6bd7f4..0000000 --- a/src/Domain/Benchmark/Test/StaticVsInstance/AccessStaticProperty.php +++ /dev/null @@ -1,25 +0,0 @@ -compute(\$i); - } - PHP; - } -} diff --git a/src/Domain/Benchmark/Test/StaticVsInstance/CallStaticMethod.php b/src/Domain/Benchmark/Test/StaticVsInstance/CallStaticMethod.php deleted file mode 100644 index 3e09c50..0000000 --- a/src/Domain/Benchmark/Test/StaticVsInstance/CallStaticMethod.php +++ /dev/null @@ -1,27 +0,0 @@ - $executionTimes + * @param array $memoryUsages + * @param array $memoryPeaks + */ + public function __construct( + public string $benchmarkId, + public string $benchmarkName, + public string $phpVersion, + public array $executionTimes, + public array $memoryUsages, + public array $memoryPeaks, + ) { + } + + public function getExecutionCount(): int + { + return count($this->executionTimes); + } + + public function isEmpty(): bool + { + return 0 === $this->getExecutionCount(); + } +} diff --git a/src/Domain/Dashboard/Model/BenchmarkStatistics.php b/src/Domain/Dashboard/Model/BenchmarkStatistics.php new file mode 100644 index 0000000..55710bd --- /dev/null +++ b/src/Domain/Dashboard/Model/BenchmarkStatistics.php @@ -0,0 +1,23 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + p50: $data['p50'] ?? 0.0, + p80: $data['p80'] ?? 0.0, + p90: $data['p90'] ?? 0.0, + p95: $data['p95'] ?? 0.0, + p99: $data['p99'] ?? 0.0, + ); + } +} diff --git a/src/Domain/Dashboard/Port/PulseRepositoryPort.php b/src/Domain/Dashboard/Port/PulseRepositoryPort.php new file mode 100644 index 0000000..7927d9b --- /dev/null +++ b/src/Domain/Dashboard/Port/PulseRepositoryPort.php @@ -0,0 +1,22 @@ + + */ + public function findUniqueBenchmarks(): array; + + /** + * Get metrics for a specific benchmark grouped by PHP version. + * + * @return BenchmarkMetrics[] + */ + public function findMetricsByBenchmark(string $benchmarkId, string $benchmarkName): array; +} diff --git a/src/Domain/Dashboard/Service/StatisticsCalculator.php b/src/Domain/Dashboard/Service/StatisticsCalculator.php new file mode 100644 index 0000000..f0fb0b9 --- /dev/null +++ b/src/Domain/Dashboard/Service/StatisticsCalculator.php @@ -0,0 +1,107 @@ +isEmpty()) { + return $this->createEmptyStatistics($benchmarkMetrics); + } + + $sortedTimes = $benchmarkMetrics->executionTimes; + sort($sortedTimes); + + $percentileMetrics = new PercentileMetrics( + p50: $this->calculatePercentile($sortedTimes, self::PERCENTILE_P50), + p80: $this->calculatePercentile($sortedTimes, self::PERCENTILE_P80), + p90: $this->calculatePercentile($sortedTimes, self::PERCENTILE_P90), + p95: $this->calculatePercentile($sortedTimes, self::PERCENTILE_P95), + p99: $this->calculatePercentile($sortedTimes, self::PERCENTILE_P99), + ); + + return new BenchmarkStatistics( + benchmarkId: $benchmarkMetrics->benchmarkId, + benchmarkName: $benchmarkMetrics->benchmarkName, + phpVersion: $benchmarkMetrics->phpVersion, + executionCount: $benchmarkMetrics->getExecutionCount(), + averageExecutionTime: $this->calculateAverage($benchmarkMetrics->executionTimes), + percentiles: $percentileMetrics, + averageMemoryUsed: $this->calculateAverage($benchmarkMetrics->memoryUsages), + peakMemoryUsed: $this->calculateMax($benchmarkMetrics->memoryPeaks), + ); + } + + /** + * @param array $sortedData + */ + private function calculatePercentile(array $sortedData, int $percentile): float + { + $count = count($sortedData); + if (0 === $count) { + return 0.0; + } + + $index = (int) ceil($percentile / self::PERCENTILE_BASE * $count) - 1; + + return $sortedData[$index] ?? end($sortedData); + } + + /** + * @param array $values + */ + private function calculateAverage(array $values): float + { + $count = count($values); + if (0 === $count) { + return 0.0; + } + + return array_sum($values) / $count; + } + + /** + * @param array $values + */ + private function calculateMax(array $values): float + { + if ([] === $values) { + return 0.0; + } + + $maxValue = max($values); + + return is_float($maxValue) || is_int($maxValue) ? (float) $maxValue : 0.0; + } + + private function createEmptyStatistics(BenchmarkMetrics $benchmarkMetrics): BenchmarkStatistics + { + return new BenchmarkStatistics( + benchmarkId: $benchmarkMetrics->benchmarkId, + benchmarkName: $benchmarkMetrics->benchmarkName, + phpVersion: $benchmarkMetrics->phpVersion, + executionCount: 0, + averageExecutionTime: 0.0, + percentiles: new PercentileMetrics(0.0, 0.0, 0.0, 0.0, 0.0), + averageMemoryUsed: 0.0, + peakMemoryUsed: 0.0, + ); + } +} diff --git a/src/Infrastructure/Async/SpatieAsyncExecutorAdapter.php b/src/Infrastructure/Async/SpatieAsyncExecutorAdapter.php new file mode 100644 index 0000000..6710d77 --- /dev/null +++ b/src/Infrastructure/Async/SpatieAsyncExecutorAdapter.php @@ -0,0 +1,40 @@ +pool = Pool::create()->concurrency($this->concurrency); + } + + public function addTask(callable $task, callable $onSuccess): void + { + $this->pool + ->add(fn (): BenchmarkResult => $task()) + ->then(function (BenchmarkResult $result) use ($onSuccess): void { + $onSuccess($result); + }); + } + + public function wait(): void + { + $this->pool->wait(); + } +} diff --git a/src/Infrastructure/Async/SymfonyEventDispatcherAdapter.php b/src/Infrastructure/Async/SymfonyEventDispatcherAdapter.php new file mode 100644 index 0000000..f48cec6 --- /dev/null +++ b/src/Infrastructure/Async/SymfonyEventDispatcherAdapter.php @@ -0,0 +1,27 @@ +eventDispatcher->dispatch($event); + } +} diff --git a/src/Infrastructure/Cli/BenchmarkCommand.php b/src/Infrastructure/Cli/BenchmarkCommand.php index 76d934d..80e9b55 100644 --- a/src/Infrastructure/Cli/BenchmarkCommand.php +++ b/src/Infrastructure/Cli/BenchmarkCommand.php @@ -4,151 +4,147 @@ namespace Jblairy\PhpBenchmark\Infrastructure\Cli; +use Exception; use Jblairy\PhpBenchmark\Application\UseCase\BenchmarkOrchestrator; use Jblairy\PhpBenchmark\Domain\Benchmark\Contract\Benchmark; -use Jblairy\PhpBenchmark\Domain\Benchmark\Port\BenchmarkRepositoryPort; +use Jblairy\PhpBenchmark\Domain\Benchmark\Exception\BenchmarkNotFound; use Jblairy\PhpBenchmark\Domain\Benchmark\Model\BenchmarkConfiguration; +use Jblairy\PhpBenchmark\Domain\Benchmark\Port\BenchmarkRepositoryPort; use Jblairy\PhpBenchmark\Domain\PhpVersion\Enum\PhpVersion; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\Option; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; #[AsCommand( name: 'benchmark:run', - description: 'Execute PHP benchmarks across different versions' + description: 'Execute PHP benchmarks across different versions', )] -final class BenchmarkCommand extends Command +final readonly class BenchmarkCommand { public function __construct( - private readonly BenchmarkOrchestrator $orchestrator, - private readonly BenchmarkRepositoryPort $registry, + private BenchmarkOrchestrator $benchmarkOrchestrator, + private BenchmarkRepositoryPort $benchmarkRepositoryPort, ) { - parent::__construct(); - } - - protected function configure(): void - { - $this - ->addOption( - 'test', - 't', - InputOption::VALUE_OPTIONAL, - 'Name of specific benchmark to run' - ) - ->addOption( - 'iterations', - 'i', - InputOption::VALUE_OPTIONAL, - 'Number of iterations to run', - 1 - ) - ->addOption( - 'php-version', - 'p', - InputOption::VALUE_OPTIONAL, - 'Specific PHP version to test (e.g., php84, php85)' - ); } - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); + public function __invoke( + OutputInterface $output, + InputInterface $input, + #[Option] + ?string $test = null, + #[Option] + int $iterations = 0, + #[Option] + ?string $phpVersion = null, + ): int { + $symfonyStyle = new SymfonyStyle($input, $output); - $testName = $input->getOption('test'); - $iterations = (int) $input->getOption('iterations'); - $phpVersionName = $input->getOption('php-version'); + $testName = $test; + $phpVersionName = $phpVersion; - if ($iterations <= 0) { - $io->error('Iterations must be greater than 0'); + if (0 >= $iterations) { + $symfonyStyle->error('Iterations must be greater than 0'); return Command::FAILURE; } try { - if ($testName !== null && $phpVersionName !== null) { - $this->executeSingleBenchmark($io, $testName, $phpVersionName, $iterations); - } elseif ($testName !== null) { - $this->executeBenchmarkAllVersions($io, $testName, $iterations); - } else { - $this->executeAllBenchmarks($io, $iterations); - } - - $io->success('Benchmark(s) completed successfully!'); + $this->executeAppropriateStrategy($symfonyStyle, $testName, $phpVersionName, $iterations); + $symfonyStyle->success('Benchmark(s) completed successfully!'); return Command::SUCCESS; - } catch (\Exception $e) { - $io->error(sprintf('Benchmark failed: %s', $e->getMessage())); + } catch (Exception $exception) { + $symfonyStyle->error(sprintf('Benchmark failed: %s', $exception->getMessage())); return Command::FAILURE; } } - private function executeSingleBenchmark( + private function executeAppropriateStrategy( SymfonyStyle $io, + ?string $testName, + ?string $phpVersionName, + int $iterations, + ): void { + if (null !== $testName && null !== $phpVersionName) { + $this->executeSingleBenchmark($io, $testName, $phpVersionName, $iterations); + + return; + } + + if (null !== $testName) { + $this->executeBenchmarkAllVersions($io, $testName, $iterations); + + return; + } + + $this->executeAllBenchmarks($io, $iterations); + } + + private function executeSingleBenchmark( + SymfonyStyle $symfonyStyle, string $testName, string $phpVersionName, - int $iterations + int $iterations, ): void { $benchmark = $this->findBenchmark($testName); $phpVersion = PhpVersion::from($phpVersionName); - $io->title(sprintf( + $symfonyStyle->title(sprintf( 'Running %s on %s (%d iterations)', $testName, $phpVersion->value, - $iterations + $iterations, )); - $configuration = new BenchmarkConfiguration( + $benchmarkConfiguration = new BenchmarkConfiguration( benchmark: $benchmark, phpVersion: $phpVersion, - iterations: $iterations + iterations: $iterations, ); - $this->orchestrator->executeSingle($configuration); + $this->benchmarkOrchestrator->executeSingle($benchmarkConfiguration); } private function executeBenchmarkAllVersions( - SymfonyStyle $io, + SymfonyStyle $symfonyStyle, string $testName, - int $iterations + int $iterations, ): void { $benchmark = $this->findBenchmark($testName); - $io->title(sprintf( + $symfonyStyle->title(sprintf( 'Running %s across all PHP versions (%d iterations)', $testName, - $iterations + $iterations, )); - $this->orchestrator->executeMultiple( + $this->benchmarkOrchestrator->executeMultiple( benchmarks: [$benchmark], phpVersions: PhpVersion::cases(), - iterations: $iterations + iterations: $iterations, ); } - private function executeAllBenchmarks(SymfonyStyle $io, int $iterations): void + private function executeAllBenchmarks(SymfonyStyle $symfonyStyle, int $iterations): void { - $io->title(sprintf( + $symfonyStyle->title(sprintf( 'Running all benchmarks across all PHP versions (%d iterations)', - $iterations + $iterations, )); - $this->orchestrator->executeAll($this->registry, $iterations); + $this->benchmarkOrchestrator->executeAll($this->benchmarkRepositoryPort, $iterations); } private function findBenchmark(string $name): Benchmark { - $benchmark = $this->registry->findBenchmarkByName($name); + $benchmark = $this->benchmarkRepositoryPort->findBenchmarkByName($name); - if ($benchmark === null) { - throw new \InvalidArgumentException( - sprintf('Benchmark "%s" not found', $name) - ); + if (!$benchmark instanceof Benchmark) { + throw new BenchmarkNotFound($name); } return $benchmark; diff --git a/src/Infrastructure/Execution/CodeExtraction/DatabaseCodeExtractor.php b/src/Infrastructure/Execution/CodeExtraction/DatabaseCodeExtractor.php new file mode 100644 index 0000000..d89a251 --- /dev/null +++ b/src/Infrastructure/Execution/CodeExtraction/DatabaseCodeExtractor.php @@ -0,0 +1,31 @@ +getMethodBody($phpVersion); + } + + return $this->fallbackExtractor->extractCode($benchmark, $phpVersion); + } +} diff --git a/src/Infrastructure/Execution/CodeExtraction/ReflectionCodeExtractor.php b/src/Infrastructure/Execution/CodeExtraction/ReflectionCodeExtractor.php index ae8be67..37d796a 100644 --- a/src/Infrastructure/Execution/CodeExtraction/ReflectionCodeExtractor.php +++ b/src/Infrastructure/Execution/CodeExtraction/ReflectionCodeExtractor.php @@ -5,28 +5,29 @@ namespace Jblairy\PhpBenchmark\Infrastructure\Execution\CodeExtraction; use Jblairy\PhpBenchmark\Domain\Benchmark\Contract\Benchmark; -use Jblairy\PhpBenchmark\Domain\Benchmark\Port\CodeExtractorPort; use Jblairy\PhpBenchmark\Domain\Benchmark\Exception\ReflexionMethodNotFound; +use Jblairy\PhpBenchmark\Domain\Benchmark\Port\CodeExtractorPort; use Jblairy\PhpBenchmark\Domain\PhpVersion\Attribute\All; use Jblairy\PhpBenchmark\Domain\PhpVersion\Enum\PhpVersion; use ReflectionClass; use ReflectionMethod; +use RuntimeException; final class ReflectionCodeExtractor implements CodeExtractorPort { public function extractCode(Benchmark $benchmark, PhpVersion $phpVersion): string { - $method = $this->findMethodForVersion($benchmark, $phpVersion); - $rawCode = $this->extractMethodBody($method); + $reflectionMethod = $this->findMethodForVersion($benchmark, $phpVersion); + $rawCode = $this->extractMethodBody($reflectionMethod); return $this->cleanCode($rawCode); } private function findMethodForVersion(Benchmark $benchmark, PhpVersion $phpVersion): ReflectionMethod { - $reflection = new ReflectionClass($benchmark); + $reflectionClass = new ReflectionClass($benchmark); - foreach ($reflection->getMethods() as $method) { + foreach ($reflectionClass->getMethods() as $method) { if ($this->methodMatchesVersion($method, $phpVersion)) { return $method; } @@ -35,12 +36,12 @@ private function findMethodForVersion(Benchmark $benchmark, PhpVersion $phpVersi throw new ReflexionMethodNotFound($benchmark::class, $phpVersion->value); } - private function methodMatchesVersion(ReflectionMethod $method, PhpVersion $phpVersion): bool + private function methodMatchesVersion(ReflectionMethod $reflectionMethod, PhpVersion $phpVersion): bool { - foreach ($method->getAttributes() as $attribute) { + foreach ($reflectionMethod->getAttributes() as $attribute) { $attributeName = $attribute->getName(); - if ($attributeName === All::class) { + if (All::class === $attributeName) { return true; } @@ -52,38 +53,49 @@ private function methodMatchesVersion(ReflectionMethod $method, PhpVersion $phpV return false; } - private function extractMethodBody(ReflectionMethod $method): string + private function extractMethodBody(ReflectionMethod $reflectionMethod): string { - $fileName = (string) $method->getFileName(); - $startLine = (int) $method->getStartLine(); - $endLine = (int) $method->getEndLine(); + $fileName = (string) $reflectionMethod->getFileName(); + $startLine = (int) $reflectionMethod->getStartLine(); + $endLine = (int) $reflectionMethod->getEndLine(); $fileLines = $this->readFileLines($fileName); return $this->extractBodyLinesBetweenBraces($fileLines, $startLine, $endLine); } + /** + * @return string[] + */ private function readFileLines(string $fileName): array { if (!file_exists($fileName)) { - throw new \RuntimeException("File not found: {$fileName}"); + throw new RuntimeException('File not found: ' . $fileName); } $fileLines = file($fileName); - if ($fileLines === false) { - throw new \RuntimeException("Cannot read file: {$fileName}"); + if (false === $fileLines) { + throw new RuntimeException('Cannot read file: ' . $fileName); } return $fileLines; } + /** + * @param string[] $fileLines + */ private function extractBodyLinesBetweenBraces(array $fileLines, int $startLine, int $endLine): string { $bodyLines = []; for ($i = $startLine; $i < $endLine - 1; ++$i) { + if (!isset($fileLines[$i])) { + continue; + } + if ($this->shouldSkipLine($fileLines[$i])) { continue; } + $bodyLines[] = $fileLines[$i]; } @@ -92,8 +104,9 @@ private function extractBodyLinesBetweenBraces(array $fileLines, int $startLine, private function shouldSkipLine(string $line): bool { - $trimmed = trim($line); - return $trimmed === '{' || $trimmed === ''; + $trimmed = mb_trim($line); + + return '{' === $trimmed || '' === $trimmed; } private function cleanCode(string $code): string @@ -101,7 +114,7 @@ private function cleanCode(string $code): string $cleaned = $this->removeHeredocMarkers($code); $cleaned = $this->unescapeHeredocVariables($cleaned); - return trim($cleaned); + return mb_trim($cleaned); } private function removeHeredocMarkers(string $code): string diff --git a/src/Infrastructure/Execution/Docker/DockerScriptExecutor.php b/src/Infrastructure/Execution/Docker/DockerScriptExecutor.php index 40aa7dc..295113c 100644 --- a/src/Infrastructure/Execution/Docker/DockerScriptExecutor.php +++ b/src/Infrastructure/Execution/Docker/DockerScriptExecutor.php @@ -7,22 +7,24 @@ use Jblairy\PhpBenchmark\Domain\Benchmark\Model\BenchmarkResult; use Jblairy\PhpBenchmark\Domain\Benchmark\Model\ExecutionContext; use Jblairy\PhpBenchmark\Domain\Benchmark\Port\ScriptExecutorPort; +use RuntimeException; final class DockerScriptExecutor implements ScriptExecutorPort { - private const TEMP_DIR = '/srv/php_benchmark/var/tmp'; + private const string TEMP_DIR = '/srv/php_benchmark/var/tmp'; - public function executeScript(ExecutionContext $context): BenchmarkResult + public function executeScript(ExecutionContext $executionContext): BenchmarkResult { - $tempFile = $this->createTempScriptFile($context->scriptContent); + $tempFile = $this->createTempScriptFile($executionContext->scriptContent); try { - $output = $this->executeInDocker($context->phpVersion->value, $tempFile); + $output = $this->executeInDocker($executionContext->phpVersion->value, $tempFile); $result = $this->parseOutput($output); $this->cleanupTempFile($tempFile); + return $result; - } catch (\RuntimeException $e) { - throw $this->enrichExceptionWithContext($e, $context, $tempFile); + } catch (RuntimeException $runtimeException) { + throw $this->enrichExceptionWithContext($runtimeException, $executionContext, $tempFile); } } @@ -31,13 +33,13 @@ private function createTempScriptFile(string $scriptContent): string $tempFile = sprintf( '%s/benchmark_script_%s.php', self::TEMP_DIR, - uniqid('', true) + uniqid('', true), ); $fullScript = "&1', escapeshellarg($phpVersion), - escapeshellarg($scriptPath) + escapeshellarg($scriptPath), ); $output = []; @@ -56,10 +58,8 @@ private function executeInDocker(string $phpVersion, string $scriptPath): string exec($command, $output, $exitCode); - if ($exitCode !== 0) { - throw new \RuntimeException( - sprintf('Script execution failed with code %d: %s', $exitCode, implode("\n", $output)) - ); + if (0 !== $exitCode) { + throw new RuntimeException(sprintf('Script execution failed with code %d: %s', $exitCode, implode("\n", $output))); } return implode('', $output); @@ -69,35 +69,50 @@ private function parseOutput(string $output): BenchmarkResult { $data = json_decode($output, true); - if (json_last_error() !== JSON_ERROR_NONE) { - throw new \RuntimeException( - sprintf('Invalid JSON output: %s. Output was: %s', json_last_error_msg(), $output) - ); + if (JSON_ERROR_NONE !== json_last_error()) { + throw new RuntimeException(sprintf('Invalid JSON output: %s. Output was: %s', json_last_error_msg(), $output)); } if (!is_array($data)) { - throw new \RuntimeException('Expected array from JSON decode'); + throw new RuntimeException('Expected array from JSON decode'); } - return BenchmarkResult::fromArray($data); + $validData = $this->ensureAssociativeArrayWithStringKeys($data); + + return BenchmarkResult::fromArray($validData); + } + + /** + * @param array $data + * + * @return array + */ + private function ensureAssociativeArrayWithStringKeys(array $data): array + { + /* @var array */ + return array_filter( + $data, + fn ($key): bool => is_string($key), + ARRAY_FILTER_USE_KEY, + ); } private function cleanupTempFile(string $tempFile): void { if (file_exists($tempFile)) { - @unlink($tempFile); + unlink($tempFile); } } private function enrichExceptionWithContext( - \RuntimeException $exception, - ExecutionContext $context, - string $tempFile - ): \RuntimeException { - return new \RuntimeException( - $exception->getMessage() . sprintf(' [Benchmark: %s, File: %s]', $context->benchmarkClassName, $tempFile), - $exception->getCode(), - $exception + RuntimeException $runtimeException, + ExecutionContext $executionContext, + string $tempFile, + ): RuntimeException { + return new RuntimeException( + $runtimeException->getMessage() . sprintf(' [Benchmark: %s, File: %s]', $executionContext->benchmarkClassName, $tempFile), + (int) $runtimeException->getCode(), + $runtimeException, ); } } diff --git a/src/Infrastructure/Execution/ScriptBuilding/InstrumentedScriptBuilder.php b/src/Infrastructure/Execution/ScriptBuilding/InstrumentedScriptBuilder.php index 1a0f2fe..1deb818 100644 --- a/src/Infrastructure/Execution/ScriptBuilding/InstrumentedScriptBuilder.php +++ b/src/Infrastructure/Execution/ScriptBuilding/InstrumentedScriptBuilder.php @@ -4,7 +4,9 @@ namespace Jblairy\PhpBenchmark\Infrastructure\Execution\ScriptBuilding; -final readonly class InstrumentedScriptBuilder +use Jblairy\PhpBenchmark\Domain\Benchmark\Port\ScriptBuilderPort; + +final readonly class InstrumentedScriptBuilder implements ScriptBuilderPort { public function build(string $methodBody): string { @@ -14,21 +16,21 @@ public function build(string $methodBody): string private function wrapWithInstrumentation(string $methodBody): string { return << round(((\$end_time - \$start_time) * 1000), 4), - "memory_used_bytes" => (\$mem_after - \$mem_before), - "memory_peak_bytes" => (\$mem_peak_after - \$mem_peak_before), - ]); - PHP; + \$start_time = microtime(true); + \$mem_before = memory_get_usage(true); + \$mem_peak_before = memory_get_peak_usage(true); + + {$methodBody} + + \$mem_after = memory_get_usage(true); + \$mem_peak_after = memory_get_peak_usage(true); + \$end_time = microtime(true); + + echo json_encode([ + "execution_time_ms" => round(((\$end_time - \$start_time) * 1000), 4), + "memory_used_bytes" => (\$mem_after - \$mem_before), + "memory_peak_bytes" => (\$mem_peak_after - \$mem_peak_before), + ]); + PHP; } } diff --git a/src/Infrastructure/Mercure/EventSubscriber/BenchmarkProgressSubscriber.php b/src/Infrastructure/Mercure/EventSubscriber/BenchmarkProgressSubscriber.php new file mode 100644 index 0000000..5823989 --- /dev/null +++ b/src/Infrastructure/Mercure/EventSubscriber/BenchmarkProgressSubscriber.php @@ -0,0 +1,64 @@ + + */ + public static function getSubscribedEvents(): array + { + return [ + BenchmarkStarted::class => 'onBenchmarkStarted', + BenchmarkProgress::class => 'onBenchmarkProgress', + BenchmarkCompleted::class => 'onBenchmarkCompleted', + ]; + } + + public function onBenchmarkStarted(BenchmarkStarted $event): void + { + $this->publishUpdate('benchmark/progress', $event->toArray()); + } + + public function onBenchmarkProgress(BenchmarkProgress $event): void + { + $this->publishUpdate('benchmark/progress', $event->toArray()); + } + + public function onBenchmarkCompleted(BenchmarkCompleted $event): void + { + $this->publishUpdate('benchmark/progress', $event->toArray()); + $this->publishUpdate('benchmark/results', $event->toArray()); + } + + /** + * @param array $data + */ + private function publishUpdate(string $topic, array $data): void + { + $update = new Update( + $topic, + json_encode($data, JSON_THROW_ON_ERROR), + ); + + $this->hub->publish($update); + } +} diff --git a/src/Infrastructure/Persistence/Doctrine/Adapter/DatabaseBenchmark.php b/src/Infrastructure/Persistence/Doctrine/Adapter/DatabaseBenchmark.php new file mode 100644 index 0000000..75de016 --- /dev/null +++ b/src/Infrastructure/Persistence/Doctrine/Adapter/DatabaseBenchmark.php @@ -0,0 +1,77 @@ +entity->supportsPhpVersion($phpVersion)) { + throw new ReflexionMethodNotFound($this->entity->getSlug(), $phpVersion->value); + } + + return $this->entity->getCode(); + } + + public function getEntity(): BenchmarkEntity + { + return $this->entity; + } + + public function getSlug(): string + { + return $this->entity->getSlug(); + } + + public function getName(): string + { + return $this->entity->getName(); + } + + public function getCategory(): string + { + return $this->entity->getCategory(); + } + + public function getDescription(): string + { + return $this->entity->getDescription(); + } + + /** + * @return string[] + */ + public function getTags(): array + { + return $this->entity->getTags(); + } + + public function getIcon(): ?string + { + return $this->entity->getIcon(); + } + + /** + * @return PhpVersion[] + */ + public function getSupportedPhpVersions(): array + { + return $this->entity->getPhpVersionEnums(); + } +} diff --git a/src/Infrastructure/Persistence/Doctrine/DoctrinePulseResultPersister.php b/src/Infrastructure/Persistence/Doctrine/DoctrinePulseResultPersister.php index 788201c..11c4da8 100644 --- a/src/Infrastructure/Persistence/Doctrine/DoctrinePulseResultPersister.php +++ b/src/Infrastructure/Persistence/Doctrine/DoctrinePulseResultPersister.php @@ -5,34 +5,47 @@ namespace Jblairy\PhpBenchmark\Infrastructure\Persistence\Doctrine; use Doctrine\ORM\EntityManagerInterface; +use Jblairy\PhpBenchmark\Domain\Benchmark\Contract\Benchmark; use Jblairy\PhpBenchmark\Domain\Benchmark\Model\BenchmarkConfiguration; use Jblairy\PhpBenchmark\Domain\Benchmark\Model\BenchmarkResult; use Jblairy\PhpBenchmark\Domain\Benchmark\Port\ResultPersisterPort; +use Jblairy\PhpBenchmark\Infrastructure\Persistence\Doctrine\Adapter\DatabaseBenchmark; use Jblairy\PhpBenchmark\Infrastructure\Persistence\Doctrine\Entity\Pulse; -final class DoctrinePulseResultPersister implements ResultPersisterPort +final readonly class DoctrinePulseResultPersister implements ResultPersisterPort { public function __construct( - private readonly EntityManagerInterface $entityManager, + private EntityManagerInterface $entityManager, ) { } - public function persist(BenchmarkConfiguration $configuration, BenchmarkResult $result): void + public function persist(BenchmarkConfiguration $benchmarkConfiguration, BenchmarkResult $benchmarkResult): void { - $pulse = $this->createPulseEntity($configuration, $result); + $pulse = $this->createPulseEntity($benchmarkConfiguration, $benchmarkResult); $this->entityManager->persist($pulse); $this->entityManager->flush(); } - private function createPulseEntity(BenchmarkConfiguration $configuration, BenchmarkResult $result): Pulse + private function createPulseEntity(BenchmarkConfiguration $benchmarkConfiguration, BenchmarkResult $benchmarkResult): Pulse { + $benchmarkIdentifier = $this->resolveBenchmarkIdentifier($benchmarkConfiguration->benchmark); + return Pulse::create( - $result->executionTimeMs, - $result->memoryUsedBytes, - $result->memoryPeakBytes, - $configuration->phpVersion, - $configuration->benchmark::class, + $benchmarkResult->executionTimeMs, + $benchmarkResult->memoryUsedBytes, + $benchmarkResult->memoryPeakBytes, + $benchmarkConfiguration->phpVersion, + $benchmarkIdentifier, ); } + + private function resolveBenchmarkIdentifier(Benchmark $benchmark): string + { + if ($benchmark instanceof DatabaseBenchmark) { + return $benchmark->getSlug(); + } + + return $benchmark::class; + } } diff --git a/src/Infrastructure/Persistence/Doctrine/Entity/Benchmark.php b/src/Infrastructure/Persistence/Doctrine/Entity/Benchmark.php new file mode 100644 index 0000000..66f5f19 --- /dev/null +++ b/src/Infrastructure/Persistence/Doctrine/Entity/Benchmark.php @@ -0,0 +1,218 @@ + + */ + #[ORM\Column(type: Types::JSON)] + private array $tags = []; + + #[ORM\Column(type: Types::STRING, length: self::ICON_MAX_LENGTH, nullable: true)] + private ?string $icon = null; + + /** + * @var array Array of PhpVersion enum values + */ + #[ORM\Column(type: Types::JSON)] + private array $phpVersions = []; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private DateTimeImmutable $createdAt; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + private DateTimeImmutable $updatedAt; + + /** + * @param array $phpVersions + * @param array $tags + */ + public function __construct( + string $slug = '', + string $name = '', + string $category = '', + string $description = '', + string $code = '', + array $phpVersions = [], + array $tags = [], + ?string $icon = null, + ) { + $this->slug = $slug; + $this->name = $name; + $this->category = $category; + $this->description = $description; + $this->code = $code; + $this->phpVersions = array_values($phpVersions); // Ensure sequential array + $this->tags = array_values($tags); // Ensure sequential array + $this->icon = $icon; + $this->createdAt = new DateTimeImmutable(); + $this->updatedAt = new DateTimeImmutable(); + } + + #[ORM\PreUpdate] + public function onPreUpdate(): void + { + $this->updatedAt = new DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getSlug(): string + { + return $this->slug; + } + + public function getName(): string + { + return $this->name; + } + + public function getCategory(): string + { + return $this->category; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getCode(): string + { + return $this->code; + } + + /** + * @return array + */ + public function getTags(): array + { + return array_values($this->tags); + } + + public function getIcon(): ?string + { + return $this->icon; + } + + /** + * @return string[] Array of PhpVersion enum values + */ + public function getPhpVersions(): array + { + return $this->phpVersions; + } + + /** + * @return PhpVersion[] + */ + public function getPhpVersionEnums(): array + { + return array_map( + fn (string $version): PhpVersion => PhpVersion::from($version), + $this->phpVersions, + ); + } + + public function supportsPhpVersion(PhpVersion $version): bool + { + return in_array($version->value, $this->phpVersions, true); + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): DateTimeImmutable + { + return $this->updatedAt; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function setCategory(string $category): void + { + $this->category = $category; + } + + public function setDescription(string $description): void + { + $this->description = $description; + } + + public function setCode(string $code): void + { + $this->code = $code; + } + + /** + * @param array $tags + */ + public function setTags(array $tags): void + { + $this->tags = array_values($tags); + } + + public function setIcon(?string $icon): void + { + $this->icon = $icon; + } + + /** + * @param array $phpVersions + */ + public function setPhpVersions(array $phpVersions): void + { + $this->phpVersions = array_values($phpVersions); + } +} diff --git a/src/Infrastructure/Persistence/Doctrine/Entity/Pulse.php b/src/Infrastructure/Persistence/Doctrine/Entity/Pulse.php index 406f60f..2b72520 100644 --- a/src/Infrastructure/Persistence/Doctrine/Entity/Pulse.php +++ b/src/Infrastructure/Persistence/Doctrine/Entity/Pulse.php @@ -39,13 +39,14 @@ public static function create( float $memoryUsedBytes, float $memoryPeakBytes, PhpVersion $phpVersion, - string $className + string $className, ): self { $pulse = new self(); $pulse->executionTimeMs = $executionTimeMs; $pulse->memoryUsedBytes = $memoryUsedBytes; $pulse->memoryPeakByte = $memoryPeakBytes; $pulse->phpVersion = $phpVersion; + $pulse->benchId = $className; $pulse->name = $className; return $pulse; diff --git a/src/Infrastructure/Persistence/Doctrine/Fixtures/BenchmarkFixtureData.php b/src/Infrastructure/Persistence/Doctrine/Fixtures/BenchmarkFixtureData.php new file mode 100644 index 0000000..2d7b018 --- /dev/null +++ b/src/Infrastructure/Persistence/Doctrine/Fixtures/BenchmarkFixtureData.php @@ -0,0 +1,41 @@ +projectDir . '/fixtures/benchmarks'; + + if (!is_dir($fixturesPath)) { + throw new RuntimeException("Fixtures directory not found: {$fixturesPath}"); + } + + $finder = new Finder(); + $finder->files() + ->in($fixturesPath) + ->name('*.yaml') + ->name('*.yml') + ->sortByName(); + + foreach ($finder as $file) { + try { + $realPath = $file->getRealPath(); + if (false === $realPath) { + continue; + } + + $data = Yaml::parseFile($realPath); + + if (!is_array($data)) { + continue; + } + + $benchmark = $this->createBenchmarkFromYaml($data, $file->getFilename()); + $manager->persist($benchmark); + } catch (Exception $e) { + $this->logFixtureLoadingError($file->getFilename(), $e); + } + } + + $manager->flush(); + } + + /** + * @param array $data + */ + private function createBenchmarkFromYaml(array $data, string $filename): Benchmark + { + $fixtureData = new BenchmarkFixtureData( + slug: $this->extractStringField($data, 'slug'), + name: $this->extractStringField($data, 'name'), + category: $this->extractStringField($data, 'category'), + code: $this->extractCodeField($data), + phpVersions: $this->extractStringArray($data, 'phpVersions'), + description: $this->extractStringField($data, 'description'), + tags: $this->extractStringArray($data, 'tags'), + icon: $this->extractNullableStringField($data, 'icon'), + ); + + $this->validateFixtureData($fixtureData, $filename); + + return $this->buildBenchmarkEntity($fixtureData); + } + + /** + * @param array $data + */ + private function extractStringField(array $data, string $key): string + { + return isset($data[$key]) && is_string($data[$key]) ? $data[$key] : ''; + } + + /** + * @param array $data + */ + private function extractNullableStringField(array $data, string $key): ?string + { + return isset($data[$key]) && is_string($data[$key]) ? $data[$key] : null; + } + + /** + * @param array $data + */ + private function extractCodeField(array $data): string + { + $code = $this->extractStringField($data, 'code'); + + return '' !== $code ? mb_trim($code) : ''; + } + + private function validateFixtureData(BenchmarkFixtureData $fixtureData, string $filename): void + { + $violations = $this->validator->validate($fixtureData); + + if (0 < count($violations)) { + $errors = []; + foreach ($violations as $violation) { + $errors[] = $violation->getPropertyPath() . ': ' . $violation->getMessage(); + } + + throw new RuntimeException(sprintf('Validation failed for %s: %s', $filename, implode(', ', $errors))); + } + } + + private function buildBenchmarkEntity(BenchmarkFixtureData $fixtureData): Benchmark + { + return new Benchmark( + slug: $fixtureData->slug, + name: $fixtureData->name, + category: $fixtureData->category, + description: $fixtureData->description, + code: $fixtureData->code, + phpVersions: $fixtureData->phpVersions, + tags: $fixtureData->tags, + icon: $fixtureData->icon, + ); + } + + /** + * @param array $data + * + * @return string[] + */ + private function extractStringArray(array $data, string $key): array + { + if (!isset($data[$key]) || !is_array($data[$key])) { + return []; + } + + return array_values(array_filter($data[$key], 'is_string')); + } + + private function logFixtureLoadingError(string $filename, Exception $exception): void + { + error_log(sprintf( + 'Failed to load benchmark fixture %s: %s', + $filename, + $exception->getMessage(), + )); + } +} diff --git a/src/Infrastructure/Persistence/Doctrine/Repository/DoctrineBenchmarkRepository.php b/src/Infrastructure/Persistence/Doctrine/Repository/DoctrineBenchmarkRepository.php new file mode 100644 index 0000000..27894b8 --- /dev/null +++ b/src/Infrastructure/Persistence/Doctrine/Repository/DoctrineBenchmarkRepository.php @@ -0,0 +1,137 @@ +entityManager + ->getRepository(BenchmarkEntity::class) + ->findAll(); + + return array_map( + fn (BenchmarkEntity $entity): Benchmark => new DatabaseBenchmark($entity), + $entities, + ); + } + + public function findBenchmarkByName(string $name): ?Benchmark + { + return $this->findBenchmarkBySlug($name) ?? $this->findBenchmarkByNameLegacy($name); + } + + public function hasBenchmark(string $name): bool + { + return $this->findBenchmarkByName($name) instanceof Benchmark; + } + + public function getDashboardStats(): DashboardStats + { + $totalBenchmarks = $this->countTotalBenchmarks(); + $result = $this->fetchPulseStatistics($totalBenchmarks); + + if (!$result instanceof DashboardStats) { + throw new RuntimeException('Unexpected result type from Doctrine SELECT NEW query'); + } + + return $result; + } + + public function getTopCategories(int $limit = 3): array + { + $results = $this->entityManager + ->createQuery(' + SELECT b.category, COUNT(b.id) as benchmark_count + FROM ' . BenchmarkEntity::class . ' b + GROUP BY b.category + ORDER BY benchmark_count DESC + ') + ->setMaxResults($limit) + ->getResult(); + + return $this->extractCategoryNamesFromQueryResults($results); + } + + private function findBenchmarkBySlug(string $slug): ?Benchmark + { + $entity = $this->entityManager + ->getRepository(BenchmarkEntity::class) + ->findOneBy(['slug' => $slug]); + + return $entity instanceof BenchmarkEntity ? new DatabaseBenchmark($entity) : null; + } + + private function findBenchmarkByNameLegacy(string $name): ?Benchmark + { + $entity = $this->entityManager + ->getRepository(BenchmarkEntity::class) + ->findOneBy(['name' => $name]); + + return $entity instanceof BenchmarkEntity ? new DatabaseBenchmark($entity) : null; + } + + private function countTotalBenchmarks(): int + { + return (int) $this->entityManager + ->createQuery('SELECT COUNT(b.id) FROM ' . BenchmarkEntity::class . ' b') + ->getSingleScalarResult(); + } + + private function fetchPulseStatistics(int $totalBenchmarks): mixed + { + return $this->entityManager + ->createQuery(' + SELECT NEW ' . DashboardStats::class . '( + :totalBenchmarks, + COUNT(DISTINCT p.phpVersion), + COUNT(DISTINCT p.benchId), + COUNT(p.id) + ) + FROM Jblairy\PhpBenchmark\Infrastructure\Persistence\Doctrine\Entity\Pulse p + ') + ->setParameter('totalBenchmarks', $totalBenchmarks) + ->getSingleResult(); + } + + /** + * @return string[] + */ + private function extractCategoryNamesFromQueryResults(mixed $results): array + { + if (!is_array($results)) { + return []; + } + + $categories = []; + foreach ($results as $row) { + if (is_array($row) && isset($row['category']) && is_string($row['category'])) { + $categories[] = $row['category']; + } + } + + return $categories; + } +} diff --git a/src/Infrastructure/Persistence/Doctrine/Repository/PulseRepository.php b/src/Infrastructure/Persistence/Doctrine/Repository/PulseRepository.php index 71220cc..b763ac2 100644 --- a/src/Infrastructure/Persistence/Doctrine/Repository/PulseRepository.php +++ b/src/Infrastructure/Persistence/Doctrine/Repository/PulseRepository.php @@ -6,73 +6,78 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; +use Jblairy\PhpBenchmark\Domain\Dashboard\Model\BenchmarkMetrics; +use Jblairy\PhpBenchmark\Domain\Dashboard\Port\PulseRepositoryPort; use Jblairy\PhpBenchmark\Infrastructure\Persistence\Doctrine\Entity\Pulse; /** * @extends ServiceEntityRepository */ -class PulseRepository extends ServiceEntityRepository +class PulseRepository extends ServiceEntityRepository implements PulseRepositoryPort { - public function __construct(ManagerRegistry $registry) + public function __construct(ManagerRegistry $managerRegistry) { - parent::__construct($registry, Pulse::class); + parent::__construct($managerRegistry, Pulse::class); } /** - * Trouve tous les benchmarks uniques (combinaisons de benchId et name) - * - * @return array - */ - public function findUniqueBenchmarks(): array - { - return $this->createQueryBuilder('p') - ->select('DISTINCT p.benchId, p.name') - ->getQuery() - ->getResult(); - } - - /** - * Calcule les statistiques pour un benchmark spécifique - * - * @param string $benchId - * @param string $name - * @return array + * @return BenchmarkMetrics[] */ - public function getStatisticsForBenchmark(string $benchId, string $name): array + public function findMetricsByBenchmark(string $benchmarkId, string $benchmarkName): array { - $pulses = $this->findBy(['benchId' => $benchId, 'name' => $name]); + $sql = <<<'SQL' + SELECT + p.bench_id, + p.name, + p.php_version, + JSON_ARRAYAGG(p.execution_time_ms) as execution_times, + JSON_ARRAYAGG(p.memory_used_bytes) as memory_usages, + JSON_ARRAYAGG(p.memory_peak_byte) as memory_peaks + FROM pulse p + WHERE p.bench_id = :benchmarkId AND p.name = :benchmarkName + GROUP BY p.bench_id, p.name, p.php_version + ORDER BY p.php_version + SQL; - if (empty($pulses)) { - return []; - } + $connection = $this->getEntityManager()->getConnection(); + $result = $connection->executeQuery($sql, [ + 'benchmarkId' => $benchmarkId, + 'benchmarkName' => $benchmarkName, + ])->fetchAllAssociative(); - $executionTimes = array_map(fn(Pulse $pulse) => $pulse->executionTimeMs, $pulses); - sort($executionTimes); - $count = count($executionTimes); + return array_map( + function (array $row): BenchmarkMetrics { + /** @var array{bench_id: string, name: string, php_version: string, execution_times: string, memory_usages: string, memory_peaks: string} $row */ + /** @var array $executionTimes */ + /** @var array $executionTimes */ + $executionTimes = json_decode($row['execution_times'], true); + /** @var array $memoryUsages */ + $memoryUsages = json_decode($row['memory_usages'], true); + /** @var array $memoryPeaks */ + $memoryPeaks = json_decode($row['memory_peaks'], true); - return [ - 'benchId' => $benchId, - 'name' => $name, - 'count' => $count, - 'avg' => array_sum($executionTimes) / $count, - 'p50' => $this->percentile($executionTimes, 50), - 'p80' => $this->percentile($executionTimes, 80), - 'p90' => $this->percentile($executionTimes, 90), - 'p95' => $this->percentile($executionTimes, 95), - 'p99' => $this->percentile($executionTimes, 99) - ]; + return new BenchmarkMetrics( + benchmarkId: $row['bench_id'], + benchmarkName: $row['name'], + phpVersion: $row['php_version'], + executionTimes: $executionTimes, + memoryUsages: $memoryUsages, + memoryPeaks: $memoryPeaks, + ); + }, + $result, + ); } /** - * Calcule une valeur de percentile pour un ensemble de données + * @return array */ - private function percentile(array $data, int $percentile): float + public function findUniqueBenchmarks(): array { - if (empty($data)) { - return 0; - } - - $index = ceil($percentile / 100 * count($data)) - 1; - return $data[$index] ?? end($data); + // @phpstan-ignore-next-line return.type + return $this->createQueryBuilder('p') + ->select('DISTINCT p.benchId, p.name') + ->getQuery() + ->getResult(); } } diff --git a/src/Infrastructure/Persistence/InMemory/InMemoryBenchmarkRepository.php b/src/Infrastructure/Persistence/InMemory/InMemoryBenchmarkRepository.php deleted file mode 100644 index e3846e0..0000000 --- a/src/Infrastructure/Persistence/InMemory/InMemoryBenchmarkRepository.php +++ /dev/null @@ -1,51 +0,0 @@ -benchmarks = iterator_to_array($benchmarks); - } - - public function getAllBenchmarks(): array - { - return $this->benchmarks; - } - - public function findBenchmarkByName(string $name): ?Benchmark - { - foreach ($this->benchmarks as $benchmark) { - if ($this->matchesBenchmarkName($benchmark, $name)) { - return $benchmark; - } - } - - return null; - } - - public function hasBenchmark(string $name): bool - { - return $this->findBenchmarkByName($name) !== null; - } - - private function matchesBenchmarkName(Benchmark $benchmark, string $searchName): bool - { - $className = $benchmark::class; - $parts = explode('\\', $className); - $shortName = end($parts); - - return $shortName === $searchName || str_ends_with($className, $searchName); - } -} diff --git a/src/Infrastructure/Web/Component/BenchmarkCardComponent.php b/src/Infrastructure/Web/Component/BenchmarkCardComponent.php new file mode 100644 index 0000000..8b100a2 --- /dev/null +++ b/src/Infrastructure/Web/Component/BenchmarkCardComponent.php @@ -0,0 +1,113 @@ +data && '' !== $this->benchmarkId && '' !== $this->benchmarkName) { + $this->data = $this->getBenchmarkStatistics->execute($this->benchmarkId, $this->benchmarkName); + } + + return $this->data; + } + + public function getChart(): ?Chart + { + if (null === $this->chart && null !== $this->getData()) { + $this->chart = $this->chartBuilder->createBenchmarkChart( + $this->getData(), + $this->getAllPhpVersions(), + ); + } + + return $this->chart; + } + + public function getCategory(): string + { + return $this->getBenchmark()?->getCategory() ?? ''; + } + + public function getShortName(): string + { + return $this->getBenchmark()?->getName() ?? $this->benchmarkId; + } + + public function getDescription(): string + { + return $this->getBenchmark()?->getDescription() ?? ''; + } + + /** + * @return string[] + */ + public function getTags(): array + { + return $this->getBenchmark()?->getTags() ?? []; + } + + public function getIcon(): ?string + { + return $this->getBenchmark()?->getIcon(); + } + + public function getSourceCode(): string + { + return $this->getBenchmark()?->getEntity()->getCode() ?? ''; + } + + /** + * @return string[] + */ + private function getAllPhpVersions(): array + { + return array_map(fn (PhpVersion $version): string => $version->value, PhpVersion::cases()); + } + + private function getBenchmark(): ?DatabaseBenchmark + { + if (null === $this->benchmark && '' !== $this->benchmarkId) { + $found = $this->benchmarkRepository->findBenchmarkByName($this->benchmarkId); + if ($found instanceof DatabaseBenchmark) { + $this->benchmark = $found; + } + } + + return $this->benchmark; + } +} diff --git a/src/Infrastructure/Web/Component/BenchmarkListComponent.php b/src/Infrastructure/Web/Component/BenchmarkListComponent.php new file mode 100644 index 0000000..6111549 --- /dev/null +++ b/src/Infrastructure/Web/Component/BenchmarkListComponent.php @@ -0,0 +1,40 @@ +|null + */ + private ?array $benchmarks = null; + + public function __construct( + private readonly PulseRepositoryPort $pulseRepository, + ) { + } + + /** + * @return array + */ + public function getBenchmarks(): array + { + if (null === $this->benchmarks) { + $this->benchmarks = $this->pulseRepository->findUniqueBenchmarks(); + } + + return $this->benchmarks; + } +} diff --git a/src/Infrastructure/Web/Component/BenchmarkProgressComponent.php b/src/Infrastructure/Web/Component/BenchmarkProgressComponent.php new file mode 100644 index 0000000..d62f6e3 --- /dev/null +++ b/src/Infrastructure/Web/Component/BenchmarkProgressComponent.php @@ -0,0 +1,27 @@ +mercurePublicUrl; + } +} diff --git a/src/Infrastructure/Web/Controller/DashboardController.php b/src/Infrastructure/Web/Controller/DashboardController.php index 3a811df..3c3cda0 100644 --- a/src/Infrastructure/Web/Controller/DashboardController.php +++ b/src/Infrastructure/Web/Controller/DashboardController.php @@ -1,109 +1,31 @@ getRepository(Pulse::class); - $pulses = $repository->findAll(); - - // Regrouper les pulses par benchmark (benchId+name) et version PHP - $benchmarks = []; - foreach ($pulses as $pulse) { - $benchmarkKey = $pulse->benchId . '_' . $pulse->name; - - if (!isset($benchmarks[$benchmarkKey])) { - $benchmarks[$benchmarkKey] = [ - 'benchId' => $pulse->benchId, - 'name' => $pulse->name, - 'versions' => [] - ]; - } - - $phpVersion = $pulse->phpVersion->value; - if (!isset($benchmarks[$benchmarkKey]['versions'][$phpVersion])) { - $benchmarks[$benchmarkKey]['versions'][$phpVersion] = [ - 'times' => [], - 'memoryUsed' => [], - 'memoryPeak' => [] - ]; - } - - $benchmarks[$benchmarkKey]['versions'][$phpVersion]['times'][] = $pulse->executionTimeMs; - $benchmarks[$benchmarkKey]['versions'][$phpVersion]['memoryUsed'][] = $pulse->memoryUsedBytes; - $benchmarks[$benchmarkKey]['versions'][$phpVersion]['memoryPeak'][] = $pulse->memoryPeakByte; - } - - // Calculer les statistiques pour chaque version PHP de chaque benchmark - $benchmarkStats = []; - foreach ($benchmarks as $benchmarkKey => $benchmark) { - $benchmarkStats[$benchmarkKey] = [ - 'benchId' => $benchmark['benchId'], - 'name' => $benchmark['name'], - 'phpVersions' => [] - ]; - - foreach ($benchmark['versions'] as $phpVersion => $data) { - sort($data['times']); - $count = count($data['times']); - - if ($count > 0) { - $benchmarkStats[$benchmarkKey]['phpVersions'][$phpVersion] = [ - 'version' => $phpVersion, - 'count' => $count, - 'avg' => array_sum($data['times']) / $count, - 'p50' => $this->percentile($data['times'], 50), - 'p80' => $this->percentile($data['times'], 80), - 'p90' => $this->percentile($data['times'], 90), - 'p95' => $this->percentile($data['times'], 95), - 'p99' => $this->percentile($data['times'], 99), - 'memoryUsed' => array_sum($data['memoryUsed']) / $count, - 'memoryPeak' => max($data['memoryPeak']) - ]; - } - } - } - - // Récupérer toutes les versions PHP disponibles dans les benchmarks - $allPhpVersions = []; - foreach ($benchmarkStats as $benchmark) { - foreach ($benchmark['phpVersions'] as $version => $stats) { - if (!in_array($version, $allPhpVersions)) { - $allPhpVersions[] = $version; - } - } - } - sort($allPhpVersions); - - foreach ($benchmarkStats as $key => $benchmark) { - $benchmarkStats[$key]['chart'] = $chartBuilder->createBenchmarkChart($benchmark, $allPhpVersions); - } - return $this->render('dashboard/index.html.twig', [ - 'stats' => $benchmarkStats, - 'allPhpVersions' => $allPhpVersions + 'mercure_public_url' => $this->mercurePublicUrl, + 'stats' => $this->benchmarkRepository->getDashboardStats(), + 'top_categories' => $this->benchmarkRepository->getTopCategories(3), ]); } - - private function percentile(array $data, int $percentile): float - { - $count = count($data); - if ($count === 0) { - return 0; - } - - $index = ceil($percentile / 100 * $count) - 1; - return $data[$index] ?? end($data); - } } diff --git a/src/Infrastructure/Web/Presentation/ChartBuilder.php b/src/Infrastructure/Web/Presentation/ChartBuilder.php new file mode 100644 index 0000000..bec0cbc --- /dev/null +++ b/src/Infrastructure/Web/Presentation/ChartBuilder.php @@ -0,0 +1,117 @@ +chartBuilder->createChart(Chart::TYPE_BAR); + + [$p50Data, $p90Data, $avgData] = $this->prepareChartData($benchmarkData, $allPhpVersions); + + $chart->setData([ + 'labels' => $this->formatVersionLabels($allPhpVersions), + 'datasets' => [ + $this->createDataset('p50 (ms)', $p50Data, ChartColor::P50_BACKGROUND, ChartColor::P50_BORDER), + $this->createDataset('p90 (ms)', $p90Data, ChartColor::P90_BACKGROUND, ChartColor::P90_BORDER), + $this->createDataset('Average (ms)', $avgData, ChartColor::AVG_BACKGROUND, ChartColor::AVG_BORDER), + ], + ]); + + $chart->setOptions($this->getChartOptions()); + + return $chart; + } + + /** + * @param string[] $allPhpVersions + * + * @return array{array, array, array} + */ + private function prepareChartData(BenchmarkData $benchmarkData, array $allPhpVersions): array + { + $p50Data = []; + $p90Data = []; + $avgData = []; + + foreach ($allPhpVersions as $phpVersion) { + $stats = $benchmarkData->phpVersions[$phpVersion] ?? null; + $p50Data[] = $stats?->getP50(); + $p90Data[] = $stats?->getP90(); + $avgData[] = $stats?->avg; + } + + return [$p50Data, $p90Data, $avgData]; + } + + /** + * @param string[] $versions + * + * @return string[] + */ + private function formatVersionLabels(array $versions): array + { + return array_map( + fn (string $version) => 'PHP ' . str_replace('php', '', $version), + $versions, + ); + } + + /** + * @param array $data + * + * @return array{label: string, data: array, backgroundColor: string, borderColor: string, borderWidth: int} + */ + private function createDataset(string $label, array $data, string $bgColor, string $borderColor): array + { + return [ + 'label' => $label, + 'data' => $data, + 'backgroundColor' => $bgColor, + 'borderColor' => $borderColor, + 'borderWidth' => 1, + ]; + } + + /** + * @return array + */ + private function getChartOptions(): array + { + return [ + 'responsive' => true, + 'scales' => [ + 'y' => [ + 'beginAtZero' => true, + 'title' => [ + 'display' => true, + 'text' => 'Execution time (ms)', + ], + ], + ], + 'plugins' => [ + 'legend' => [ + 'position' => 'top', + ], + ], + ]; + } +} diff --git a/src/Infrastructure/Web/Presentation/ChartColor.php b/src/Infrastructure/Web/Presentation/ChartColor.php new file mode 100644 index 0000000..fecc3cf --- /dev/null +++ b/src/Infrastructure/Web/Presentation/ChartColor.php @@ -0,0 +1,25 @@ + - + - {% block title %}Welcome!{% endblock %} - + + {% block title %}PHP Benchmark{% endblock %} + + {% block stylesheets %} {% endblock %} @@ -12,7 +14,13 @@ {% block importmap %}{{ importmap('app') }}{% endblock %} {% endblock %} - - {% block body %}{% endblock %} + + {% include 'components/_header.html.twig' %} + +
+ {% block body %}{% endblock %} +
+ + {% include 'components/_footer.html.twig' %} diff --git a/templates/components/BenchmarkCard.html.twig b/templates/components/BenchmarkCard.html.twig new file mode 100644 index 0000000..aa88d06 --- /dev/null +++ b/templates/components/BenchmarkCard.html.twig @@ -0,0 +1,231 @@ +
+ + {% if this.data %} + {# Loading indicator overlay - non-intrusive #} +
+
+
+ +
+
+
+
+
{{ this.category }}
+ +
+

+ {% if this.icon %}{{ this.icon }} {% endif %}{{ this.shortName }} +

+
+ + {% if this.description %} +
+ {{ this.description }} +
+ {% endif %} + + {% if this.tags|length > 0 %} +
+ {% for tag in this.tags %} + {{ tag }} + {% endfor %} +
+ {% endif %} + + {% if this.sourceCode %} +
+
+ Code testé + PHP +
+
{{ this.sourceCode }}
+
+ {% endif %} + +
+ {{ render_chart(this.chart) }} +
+ +
+ + + + + {% for phpVersion in this.data.phpVersions|keys %} + + {% endfor %} + + + + + + {% for stats in this.data.phpVersions %} + + {% endfor %} + + + + {% set best_p50 = 999999 %} + {% for stats in this.data.phpVersions %} + {% if stats.getP50() < best_p50 %} + {% set best_p50 = stats.getP50() %} + {% endif %} + {% endfor %} + + {% for stats in this.data.phpVersions %} + + {% endfor %} + + + + {% set best_p80 = 999999 %} + {% for stats in this.data.phpVersions %} + {% if stats.getP80() < best_p80 %} + {% set best_p80 = stats.getP80() %} + {% endif %} + {% endfor %} + + {% for stats in this.data.phpVersions %} + + {% endfor %} + + + + {% set best_p90 = 999999 %} + {% for stats in this.data.phpVersions %} + {% if stats.getP90() < best_p90 %} + {% set best_p90 = stats.getP90() %} + {% endif %} + {% endfor %} + + {% for stats in this.data.phpVersions %} + + {% endfor %} + + + + {% set best_p95 = 999999 %} + {% for stats in this.data.phpVersions %} + {% if stats.getP95() < best_p95 %} + {% set best_p95 = stats.getP95() %} + {% endif %} + {% endfor %} + + {% for stats in this.data.phpVersions %} + + {% endfor %} + + + + {% set best_p99 = 999999 %} + {% for stats in this.data.phpVersions %} + {% if stats.getP99() < best_p99 %} + {% set best_p99 = stats.getP99() %} + {% endif %} + {% endfor %} + + {% for stats in this.data.phpVersions %} + + {% endfor %} + + + + {% set best_avg = 999999 %} + {% for stats in this.data.phpVersions %} + {% if stats.avg < best_avg %} + {% set best_avg = stats.avg %} + {% endif %} + {% endfor %} + + {% for stats in this.data.phpVersions %} + + {% endfor %} + + + + {% set best_memory = 999999999999 %} + {% for stats in this.data.phpVersions %} + {% if stats.memoryUsed < best_memory %} + {% set best_memory = stats.memoryUsed %} + {% endif %} + {% endfor %} + + {% for stats in this.data.phpVersions %} + + {% endfor %} + + + + {% set best_memory_peak = 999999999999 %} + {% for stats in this.data.phpVersions %} + {% if stats.memoryPeak < best_memory_peak %} + {% set best_memory_peak = stats.memoryPeak %} + {% endif %} + {% endfor %} + + {% for stats in this.data.phpVersions %} + + {% endfor %} + + +
Métrique + PHP {{ phpVersion|replace({'php': ''}) }} +
Échantillons{{ stats.count }}
p50 (ms) + {{ stats.getP50()|number_format(5) }} +
p80 (ms) + {{ stats.getP80()|number_format(5) }} +
p90 (ms) + {{ stats.getP90()|number_format(5) }} +
p95 (ms) + {{ stats.getP95()|number_format(5) }} +
p99 (ms) + {{ stats.getP99()|number_format(5) }} +
Moyenne (ms) + {{ stats.avg|number_format(5) }} +
Mémoire utilisée (Mo) + {{ (stats.memoryUsed / 1024 / 1024)|number_format(2) }} +
Pic de mémoire (Mo) + {{ (stats.memoryPeak / 1024 / 1024)|number_format(2) }} +
+
+
+
+ {% else %} + {# Initial loading state - only shown on first load #} +
+
+

{{ this.benchmarkName }}

+
+ Chargement des statistiques... +
+
+
+ {% endif %} +
diff --git a/templates/components/BenchmarkList.html.twig b/templates/components/BenchmarkList.html.twig new file mode 100644 index 0000000..6726194 --- /dev/null +++ b/templates/components/BenchmarkList.html.twig @@ -0,0 +1,19 @@ +
+
+
+ Chargement de la liste des benchmarks... +
+
+ + {% if this.benchmarks %} +
+ {% for benchmark in this.benchmarks %} + + {% endfor %} +
+ {% endif %} +
diff --git a/templates/components/BenchmarkProgress.html.twig b/templates/components/BenchmarkProgress.html.twig new file mode 100644 index 0000000..4f96f7e --- /dev/null +++ b/templates/components/BenchmarkProgress.html.twig @@ -0,0 +1,33 @@ +
+ +
+

Waiting for benchmark...

+ +
+ +
+

⏳ En attente de démarrage d'un benchmark...

+
+ + + + + + +
diff --git a/templates/components/_footer.html.twig b/templates/components/_footer.html.twig new file mode 100644 index 0000000..27a55e1 --- /dev/null +++ b/templates/components/_footer.html.twig @@ -0,0 +1,56 @@ +
+ +
diff --git a/templates/components/_header.html.twig b/templates/components/_header.html.twig new file mode 100644 index 0000000..64bdb3b --- /dev/null +++ b/templates/components/_header.html.twig @@ -0,0 +1,20 @@ +
+ +
diff --git a/templates/dashboard/index.html.twig b/templates/dashboard/index.html.twig index 6cdb346..cce9428 100644 --- a/templates/dashboard/index.html.twig +++ b/templates/dashboard/index.html.twig @@ -1,239 +1,159 @@ {% extends 'base.html.twig' %} -{% block title %}Comparaison par version PHP{% endblock %} +{% block title %}PHP Version Comparison{% endblock %} {% block javascripts %} {{ parent() }} - {{ encore_entry_script_tags('app') }} {% endblock %} {% block body %} -
-

Comparaison par version PHP

+
- {% if stats|length > 0 %} -
-

Vue d'ensemble des benchmarks

-

Nombre total de benchmarks: {{ stats|length }}

-

Versions PHP comparées: {{ allPhpVersions|join(', ') }}

-
- - {% for benchmarkKey, benchmark in stats %} -
-
-
-

{{ benchmark.name }} ({{ benchmark.benchId }})

-
- - -
-
- -
- {{ render_chart(benchmark.chart) }} -
- - - - - - {% for version in allPhpVersions %} - - {% endfor %} - - - - - - {% for version in allPhpVersions %} - - {% endfor %} - - - - {% set best_p50 = 999999 %} - {% for version in allPhpVersions %} - {% if benchmark.phpVersions[version] is defined and benchmark.phpVersions[version].p50 < best_p50 %} - {% set best_p50 = benchmark.phpVersions[version].p50 %} - {% endif %} - {% endfor %} - - {% for version in allPhpVersions %} - - {% endfor %} - - - - {% set best_p80 = 999999 %} - {% for version in allPhpVersions %} - {% if benchmark.phpVersions[version] is defined and benchmark.phpVersions[version].p80 < best_p80 %} - {% set best_p80 = benchmark.phpVersions[version].p80 %} - {% endif %} - {% endfor %} - - {% for version in allPhpVersions %} - - {% endfor %} - - - - {% set best_p90 = 999999 %} - {% for version in allPhpVersions %} - {% if benchmark.phpVersions[version] is defined and benchmark.phpVersions[version].p90 < best_p90 %} - {% set best_p90 = benchmark.phpVersions[version].p90 %} - {% endif %} - {% endfor %} +

PHP Performance Benchmarks

- {% for version in allPhpVersions %} - - {% endfor %} - - - - {% set best_p95 = 999999 %} - {% for version in allPhpVersions %} - {% if benchmark.phpVersions[version] is defined and benchmark.phpVersions[version].p95 < best_p95 %} - {% set best_p95 = benchmark.phpVersions[version].p95 %} - {% endif %} - {% endfor %} - - {% for version in allPhpVersions %} - - {% endfor %} - - - - {% set best_p99 = 999999 %} - {% for version in allPhpVersions %} - {% if benchmark.phpVersions[version] is defined and benchmark.phpVersions[version].p99 < best_p99 %} - {% set best_p99 = benchmark.phpVersions[version].p99 %} - {% endif %} - {% endfor %} + {# Statistics Overview #} +
+
+
📦
+
+
{{ stats.totalBenchmarks }}
+
Available Benchmarks
+
+
+
+
🚀
+
+
{{ stats.phpVersionsTested }}
+
PHP Versions
+
+
+
+
+
+
{{ stats.benchmarksExecuted }}
+
Benchmarks Executed
+
+
+
+
+
+
{{ stats.totalExecutions }}
+
Total Test Runs
+
+
+
- {% for version in allPhpVersions %} - - {% endfor %} - - - - {% set best_avg = 999999 %} - {% for version in allPhpVersions %} - {% if benchmark.phpVersions[version] is defined and benchmark.phpVersions[version].avg < best_avg %} - {% set best_avg = benchmark.phpVersions[version].avg %} - {% endif %} - {% endfor %} + {# Filter and Sort Section #} +
+
+ {# Search Bar #} +
+
+ 🔍 + +
+ +
- {% for version in allPhpVersions %} -
- {% endfor %} - - - - {% set best_memory = 999999999999 %} - {% for version in allPhpVersions %} - {% if benchmark.phpVersions[version] is defined and benchmark.phpVersions[version].memoryUsed < best_memory %} - {% set best_memory = benchmark.phpVersions[version].memoryUsed %} - {% endif %} - {% endfor %} + {# Sort and Filter Buttons #} +
+
+ Sort by: + + +
- {% for version in allPhpVersions %} -
- {% endfor %} - - - - {% set best_memory_peak = 999999999999 %} - {% for version in allPhpVersions %} - {% if benchmark.phpVersions[version] is defined and benchmark.phpVersions[version].memoryPeak < best_memory_peak %} - {% set best_memory_peak = benchmark.phpVersions[version].memoryPeak %} - {% endif %} - {% endfor %} +
+ Category: + + {% for category in top_categories %} + + {% endfor %} +
+ - {% for version in allPhpVersions %} - - {% endfor %} - - -
Métrique - PHP {{ version|replace({'php': ''}) }}
Échantillons - {% if benchmark.phpVersions[version] is defined %} - {{ benchmark.phpVersions[version].count }} - {% else %} - - - {% endif %} -
p50 (ms) - {% if benchmark.phpVersions[version] is defined %} - {{ benchmark.phpVersions[version].p50|number_format(5) }} - {% else %} - - - {% endif %} -
p80 (ms) - {% if benchmark.phpVersions[version] is defined %} - {{ benchmark.phpVersions[version].p80|number_format(5) }} - {% else %} - - - {% endif %} -
p90 (ms) - {% if benchmark.phpVersions[version] is defined %} - {{ benchmark.phpVersions[version].p90|number_format(5) }} - {% else %} - - - {% endif %} -
p95 (ms) - {% if benchmark.phpVersions[version] is defined %} - {{ benchmark.phpVersions[version].p95|number_format(5) }} - {% else %} - - - {% endif %} -
p99 (ms) - {% if benchmark.phpVersions[version] is defined %} - {{ benchmark.phpVersions[version].p99|number_format(5) }} - {% else %} - - - {% endif %} -
Moyenne (ms) - {% if benchmark.phpVersions[version] is defined %} - {{ benchmark.phpVersions[version].avg|number_format(5) }} - {% else %} - - - {% endif %} -
Mémoire utilisée (Mo) - {% if benchmark.phpVersions[version] is defined %} - {{ (benchmark.phpVersions[version].memoryUsed / 1024 / 1024)|number_format(2) }} - {% else %} - - - {% endif %} -
Pic de mémoire (Mo) - {% if benchmark.phpVersions[version] is defined %} - {{ (benchmark.phpVersions[version].memoryPeak / 1024 / 1024)|number_format(2) }} - {% else %} - - - {% endif %} -
+ {# Stats #} +
+ Loading... + + {# Global Unit Switch - Compact pill-shaped toggle #} +
+ +
- {% endfor %} - {% else %} -
-

Aucune donnée de performance disponible.

- {% endif %} +
+ + {# Benchmark Cards Container #} +
+ + + {# No Results Message #} + +
{% endblock %} diff --git a/tests/Unit/Application/Dashboard/DTO/BenchmarkStatisticsDataTest.php b/tests/Unit/Application/Dashboard/DTO/BenchmarkStatisticsDataTest.php new file mode 100644 index 0000000..72c9518 --- /dev/null +++ b/tests/Unit/Application/Dashboard/DTO/BenchmarkStatisticsDataTest.php @@ -0,0 +1,153 @@ +benchmarkId); + self::assertSame('Test Benchmark', $dto->benchmarkName); + self::assertSame('php84', $dto->phpVersion); + self::assertSame(100, $dto->count); + self::assertSame(1.8, $dto->avg); + self::assertSame($percentiles, $dto->percentiles); + self::assertSame(1024.5, $dto->memoryUsed); + self::assertSame(2048.0, $dto->memoryPeak); + self::assertSame(1.5, $dto->getP50()); + self::assertSame(2.0, $dto->getP80()); + self::assertSame(2.5, $dto->getP90()); + self::assertSame(3.0, $dto->getP95()); + self::assertSame(4.0, $dto->getP99()); + } + + public function testPercentilesAreAccessibleViaGetters(): void + { + // Arrange + $percentiles = new PercentileMetrics( + p50: 1.5, + p80: 2.0, + p90: 2.5, + p95: 3.0, + p99: 4.0, + ); + + $dto = new BenchmarkStatisticsData( + benchmarkId: 'test', + benchmarkName: 'Test', + phpVersion: 'php84', + count: 100, + avg: 1.8, + percentiles: $percentiles, + memoryUsed: 1024.5, + memoryPeak: 2048.0, + ); + + // Act & Assert - All percentiles accessible via getter methods + self::assertSame(1.5, $dto->getP50()); + self::assertSame(2.0, $dto->getP80()); + self::assertSame(2.5, $dto->getP90()); + self::assertSame(3.0, $dto->getP95()); + self::assertSame(4.0, $dto->getP99()); + } + + public function testFromDomainCreatesDTO(): void + { + // Arrange + $percentiles = new PercentileMetrics( + p50: 1.5, + p80: 2.0, + p90: 2.5, + p95: 3.0, + p99: 4.0, + ); + + $domainStats = new BenchmarkStatistics( + benchmarkId: 'test-benchmark', + benchmarkName: 'Test Benchmark', + phpVersion: 'php84', + executionCount: 100, + averageExecutionTime: 1.8, + percentiles: $percentiles, + averageMemoryUsed: 1024.5, + peakMemoryUsed: 2048.0, + ); + + // Act + $dto = BenchmarkStatisticsData::fromDomain($domainStats); + + // Assert + self::assertSame('test-benchmark', $dto->benchmarkId); + self::assertSame('Test Benchmark', $dto->benchmarkName); + self::assertSame('php84', $dto->phpVersion); + self::assertSame(100, $dto->count); + self::assertSame(1.8, $dto->avg); + self::assertSame($percentiles, $dto->percentiles); + self::assertSame(1024.5, $dto->memoryUsed); + self::assertSame(2048.0, $dto->memoryPeak); + } + + public function testFromDomainPreservesPercentiles(): void + { + // Arrange + $percentiles = new PercentileMetrics( + p50: 10.0, + p80: 20.0, + p90: 30.0, + p95: 40.0, + p99: 50.0, + ); + + $domainStats = new BenchmarkStatistics( + benchmarkId: 'test', + benchmarkName: 'Test', + phpVersion: 'php85', + executionCount: 50, + averageExecutionTime: 25.0, + percentiles: $percentiles, + averageMemoryUsed: 512.0, + peakMemoryUsed: 1024.0, + ); + + // Act + $dto = BenchmarkStatisticsData::fromDomain($domainStats); + + // Assert - Verify percentiles are accessible via getter methods + self::assertSame(10.0, $dto->getP50()); + self::assertSame(20.0, $dto->getP80()); + self::assertSame(30.0, $dto->getP90()); + self::assertSame(40.0, $dto->getP95()); + self::assertSame(50.0, $dto->getP99()); + } +} diff --git a/tests/Unit/Application/UseCase/AsyncBenchmarkRunnerTest.php b/tests/Unit/Application/UseCase/AsyncBenchmarkRunnerTest.php new file mode 100644 index 0000000..a9b5c8b --- /dev/null +++ b/tests/Unit/Application/UseCase/AsyncBenchmarkRunnerTest.php @@ -0,0 +1,239 @@ +benchmarkExecutor = $this->createMock(BenchmarkExecutorPort::class); + $this->resultPersister = $this->createMock(ResultPersisterPort::class); + $this->eventDispatcher = $this->createMock(EventDispatcherPort::class); + $this->asyncExecutor = $this->createMock(AsyncExecutorPort::class); + + $this->runner = new AsyncBenchmarkRunner( + benchmarkExecutorPort: $this->benchmarkExecutor, + resultPersisterPort: $this->resultPersister, + eventDispatcher: $this->eventDispatcher, + asyncExecutor: $this->asyncExecutor, + ); + } + + public function testRunDispatchesBenchmarkStartedEvent(): void + { + // Arrange + $benchmark = $this->createMock(Benchmark::class); + + $configuration = new BenchmarkConfiguration( + benchmark: $benchmark, + phpVersion: PhpVersion::PHP_8_4, + iterations: 1, + ); + + $result = new BenchmarkResult( + executionTimeMs: 1.0, + memoryUsedBytes: 100, + memoryPeakBytes: 200, + ); + + $this->asyncExecutor->method('addTask')->willReturnCallback( + function (callable $task, callable $onSuccess) use ($result): void { + $onSuccess($result); + }, + ); + + // Assert BenchmarkStarted event is dispatched + $dispatchedEvents = []; + $this->eventDispatcher + ->expects(self::exactly(3)) + ->method('dispatch') + ->willReturnCallback(function (object $event) use (&$dispatchedEvents): void { + $dispatchedEvents[] = $event; + }); + + // Act + $this->runner->run($configuration); + + // Assert + self::assertCount(3, $dispatchedEvents); + self::assertInstanceOf(BenchmarkStarted::class, $dispatchedEvents[0]); + self::assertInstanceOf(BenchmarkProgress::class, $dispatchedEvents[1]); + self::assertInstanceOf(BenchmarkCompleted::class, $dispatchedEvents[2]); + } + + public function testRunExecutesBenchmarkAndPersistsResults(): void + { + // Arrange + $benchmark = $this->createMock(Benchmark::class); + + $configuration = new BenchmarkConfiguration( + benchmark: $benchmark, + phpVersion: PhpVersion::PHP_8_4, + iterations: 3, + ); + + $result = new BenchmarkResult( + executionTimeMs: 1.23, + memoryUsedBytes: 1024, + memoryPeakBytes: 2048, + ); + + $this->benchmarkExecutor + ->expects(self::never()) + ->method('execute'); + + $this->asyncExecutor + ->expects(self::exactly(3)) + ->method('addTask') + ->willReturnCallback(function (callable $task, callable $onSuccess) use ($result): void { + // Simulate async execution + $onSuccess($result); + }); + + $this->resultPersister + ->expects(self::exactly(3)) + ->method('persist') + ->with($configuration, $result); + + $this->asyncExecutor + ->expects(self::once()) + ->method('wait'); + + // Act + $this->runner->run($configuration); + } + + public function testRunDispatchesProgressEventsForEachIteration(): void + { + // Arrange + $benchmark = $this->createMock(Benchmark::class); + + $configuration = new BenchmarkConfiguration( + benchmark: $benchmark, + phpVersion: PhpVersion::PHP_8_4, + iterations: 2, + ); + + $result = new BenchmarkResult( + executionTimeMs: 1.0, + memoryUsedBytes: 100, + memoryPeakBytes: 200, + ); + + $this->asyncExecutor->method('addTask')->willReturnCallback( + function (callable $task, callable $onSuccess) use ($result): void { + $onSuccess($result); + }, + ); + + $dispatchedEvents = []; + $this->eventDispatcher + ->method('dispatch') + ->willReturnCallback(function (object $event) use (&$dispatchedEvents): void { + $dispatchedEvents[] = $event; + }); + + // Act + $this->runner->run($configuration); + + // Assert: Should have BenchmarkStarted + 2 Progress + BenchmarkCompleted + self::assertCount(4, $dispatchedEvents); + self::assertInstanceOf(BenchmarkStarted::class, $dispatchedEvents[0]); + self::assertInstanceOf(BenchmarkProgress::class, $dispatchedEvents[1]); + self::assertInstanceOf(BenchmarkProgress::class, $dispatchedEvents[2]); + self::assertInstanceOf(BenchmarkCompleted::class, $dispatchedEvents[3]); + } + + public function testRunDispatchesCompletedEventAfterAllIterations(): void + { + // Arrange + $benchmark = $this->createMock(Benchmark::class); + + $configuration = new BenchmarkConfiguration( + benchmark: $benchmark, + phpVersion: PhpVersion::PHP_8_4, + iterations: 1, + ); + + $this->asyncExecutor->method('addTask')->willReturnCallback( + function (callable $task, callable $onSuccess): void { + $result = new BenchmarkResult( + executionTimeMs: 1.0, + memoryUsedBytes: 100, + memoryPeakBytes: 200, + ); + $onSuccess($result); + }, + ); + + $completedEventDispatched = false; + $this->eventDispatcher + ->method('dispatch') + ->willReturnCallback(function (object $event) use (&$completedEventDispatched): void { + if ($event instanceof BenchmarkCompleted) { + $completedEventDispatched = true; + } + }); + + // Act + $this->runner->run($configuration); + + // Assert + self::assertTrue($completedEventDispatched); + } + + public function testRunCallsAsyncExecutorWait(): void + { + // Arrange + $benchmark = $this->createMock(Benchmark::class); + + $configuration = new BenchmarkConfiguration( + benchmark: $benchmark, + phpVersion: PhpVersion::PHP_8_4, + iterations: 1, + ); + + $this->asyncExecutor->method('addTask')->willReturnCallback( + function (callable $task, callable $onSuccess): void { + $result = new BenchmarkResult( + executionTimeMs: 1.0, + memoryUsedBytes: 100, + memoryPeakBytes: 200, + ); + $onSuccess($result); + }, + ); + + $this->asyncExecutor + ->expects(self::once()) + ->method('wait'); + + // Act + $this->runner->run($configuration); + } +} diff --git a/tests/Unit/Domain/Benchmark/Model/BenchmarkResultTest.php b/tests/Unit/Domain/Benchmark/Model/BenchmarkResultTest.php new file mode 100644 index 0000000..855d7ac --- /dev/null +++ b/tests/Unit/Domain/Benchmark/Model/BenchmarkResultTest.php @@ -0,0 +1,201 @@ +executionTimeMs); + self::assertSame(1024.0, $result->memoryUsedBytes); + self::assertSame(2048.0, $result->memoryPeakBytes); + } + + public function testFromArrayWithValidData(): void + { + $data = [ + 'execution_time_ms' => 15.75, + 'memory_used_bytes' => 2048.5, + 'memory_peak_bytes' => 4096.25, + ]; + + $result = BenchmarkResult::fromArray($data); + + self::assertSame(15.75, $result->executionTimeMs); + self::assertSame(2048.5, $result->memoryUsedBytes); + self::assertSame(4096.25, $result->memoryPeakBytes); + } + + public function testFromArrayWithIntegerValues(): void + { + $data = [ + 'execution_time_ms' => 10, + 'memory_used_bytes' => 1024, + 'memory_peak_bytes' => 2048, + ]; + + $result = BenchmarkResult::fromArray($data); + + self::assertSame(10.0, $result->executionTimeMs); + self::assertSame(1024.0, $result->memoryUsedBytes); + self::assertSame(2048.0, $result->memoryPeakBytes); + } + + public function testFromArrayWithStringNumericValues(): void + { + $data = [ + 'execution_time_ms' => '12.5', + 'memory_used_bytes' => '1024.0', + 'memory_peak_bytes' => '2048.5', + ]; + + $result = BenchmarkResult::fromArray($data); + + self::assertSame(12.5, $result->executionTimeMs); + self::assertSame(1024.0, $result->memoryUsedBytes); + self::assertSame(2048.5, $result->memoryPeakBytes); + } + + public function testFromArrayWithMissingKeysDefaultsToZero(): void + { + $data = []; + + $result = BenchmarkResult::fromArray($data); + + self::assertSame(0.0, $result->executionTimeMs); + self::assertSame(0.0, $result->memoryUsedBytes); + self::assertSame(0.0, $result->memoryPeakBytes); + } + + public function testFromArrayWithPartialDataDefaultsMissingToZero(): void + { + $data = [ + 'execution_time_ms' => 25.5, + ]; + + $result = BenchmarkResult::fromArray($data); + + self::assertSame(25.5, $result->executionTimeMs); + self::assertSame(0.0, $result->memoryUsedBytes); + self::assertSame(0.0, $result->memoryPeakBytes); + } + + public function testFromArrayWithInvalidNonNumericValuesDefaultsToZero(): void + { + $data = [ + 'execution_time_ms' => 'invalid', + 'memory_used_bytes' => 'not-a-number', + 'memory_peak_bytes' => null, + ]; + + $result = BenchmarkResult::fromArray($data); + + self::assertSame(0.0, $result->executionTimeMs); + self::assertSame(0.0, $result->memoryUsedBytes); + self::assertSame(0.0, $result->memoryPeakBytes); + } + + public function testFromArrayWithBooleanValuesDefaultsToZero(): void + { + $data = [ + 'execution_time_ms' => true, + 'memory_used_bytes' => false, + 'memory_peak_bytes' => true, + ]; + + $result = BenchmarkResult::fromArray($data); + + self::assertSame(0.0, $result->executionTimeMs); + self::assertSame(0.0, $result->memoryUsedBytes); + self::assertSame(0.0, $result->memoryPeakBytes); + } + + public function testFromArrayWithArrayValuesDefaultsToZero(): void + { + $data = [ + 'execution_time_ms' => [10.5], + 'memory_used_bytes' => ['value' => 1024], + 'memory_peak_bytes' => [], + ]; + + $result = BenchmarkResult::fromArray($data); + + self::assertSame(0.0, $result->executionTimeMs); + self::assertSame(0.0, $result->memoryUsedBytes); + self::assertSame(0.0, $result->memoryPeakBytes); + } + + public function testFromArrayWithNegativeValues(): void + { + $data = [ + 'execution_time_ms' => -10.5, + 'memory_used_bytes' => -1024.0, + 'memory_peak_bytes' => -2048.0, + ]; + + $result = BenchmarkResult::fromArray($data); + + // Negative values are allowed (edge case) + self::assertSame(-10.5, $result->executionTimeMs); + self::assertSame(-1024.0, $result->memoryUsedBytes); + self::assertSame(-2048.0, $result->memoryPeakBytes); + } + + public function testFromArrayWithZeroValues(): void + { + $data = [ + 'execution_time_ms' => 0, + 'memory_used_bytes' => 0.0, + 'memory_peak_bytes' => 0, + ]; + + $result = BenchmarkResult::fromArray($data); + + self::assertSame(0.0, $result->executionTimeMs); + self::assertSame(0.0, $result->memoryUsedBytes); + self::assertSame(0.0, $result->memoryPeakBytes); + } + + public function testFromArrayWithVeryLargeNumbers(): void + { + $data = [ + 'execution_time_ms' => 999999.99, + 'memory_used_bytes' => 1073741824.0, // 1GB + 'memory_peak_bytes' => 2147483648.0, // 2GB + ]; + + $result = BenchmarkResult::fromArray($data); + + self::assertSame(999999.99, $result->executionTimeMs); + self::assertSame(1073741824.0, $result->memoryUsedBytes); + self::assertSame(2147483648.0, $result->memoryPeakBytes); + } + + public function testFromArrayWithExtraKeysIgnoresThem(): void + { + $data = [ + 'execution_time_ms' => 10.0, + 'memory_used_bytes' => 1024.0, + 'memory_peak_bytes' => 2048.0, + 'extra_field' => 'ignored', + 'another_field' => 123, + ]; + + $result = BenchmarkResult::fromArray($data); + + self::assertSame(10.0, $result->executionTimeMs); + self::assertSame(1024.0, $result->memoryUsedBytes); + self::assertSame(2048.0, $result->memoryPeakBytes); + } +} diff --git a/tests/Unit/Domain/Dashboard/Model/BenchmarkMetricsTest.php b/tests/Unit/Domain/Dashboard/Model/BenchmarkMetricsTest.php new file mode 100644 index 0000000..5c22394 --- /dev/null +++ b/tests/Unit/Domain/Dashboard/Model/BenchmarkMetricsTest.php @@ -0,0 +1,102 @@ +benchmarkId); + self::assertSame('Test Benchmark', $metrics->benchmarkName); + self::assertSame('php84', $metrics->phpVersion); + self::assertSame([10.0, 20.0, 30.0], $metrics->executionTimes); + self::assertSame([100.0, 200.0, 300.0], $metrics->memoryUsages); + self::assertSame([150.0, 250.0, 350.0], $metrics->memoryPeaks); + } + + public function testGetExecutionCountReturnsCorrectCount(): void + { + $metrics = new BenchmarkMetrics( + benchmarkId: 'test-id', + benchmarkName: 'Test', + phpVersion: 'php84', + executionTimes: [10.0, 20.0, 30.0], + memoryUsages: [100.0], + memoryPeaks: [100.0], + ); + + self::assertSame(3, $metrics->getExecutionCount()); + } + + public function testGetExecutionCountReturnsZeroForEmptyArray(): void + { + $metrics = new BenchmarkMetrics( + benchmarkId: 'test-id', + benchmarkName: 'Test', + phpVersion: 'php84', + executionTimes: [], + memoryUsages: [], + memoryPeaks: [], + ); + + self::assertSame(0, $metrics->getExecutionCount()); + } + + public function testIsEmptyReturnsTrueForNoExecutions(): void + { + $metrics = new BenchmarkMetrics( + benchmarkId: 'test-id', + benchmarkName: 'Test', + phpVersion: 'php84', + executionTimes: [], + memoryUsages: [], + memoryPeaks: [], + ); + + self::assertTrue($metrics->isEmpty()); + } + + public function testIsEmptyReturnsFalseForExecutions(): void + { + $metrics = new BenchmarkMetrics( + benchmarkId: 'test-id', + benchmarkName: 'Test', + phpVersion: 'php84', + executionTimes: [10.0], + memoryUsages: [100.0], + memoryPeaks: [100.0], + ); + + self::assertFalse($metrics->isEmpty()); + } + + public function testValueObjectIsReadonly(): void + { + $metrics = new BenchmarkMetrics( + benchmarkId: 'test-id', + benchmarkName: 'Test', + phpVersion: 'php84', + executionTimes: [10.0], + memoryUsages: [100.0], + memoryPeaks: [100.0], + ); + + $reflection = new ReflectionClass($metrics); + self::assertTrue($reflection->isReadOnly()); + } +} diff --git a/tests/Unit/Domain/Dashboard/Model/PercentileMetricsTest.php b/tests/Unit/Domain/Dashboard/Model/PercentileMetricsTest.php new file mode 100644 index 0000000..2162393 --- /dev/null +++ b/tests/Unit/Domain/Dashboard/Model/PercentileMetricsTest.php @@ -0,0 +1,129 @@ +p50); + self::assertSame(15.2, $metrics->p80); + self::assertSame(18.7, $metrics->p90); + self::assertSame(22.1, $metrics->p95); + self::assertSame(30.5, $metrics->p99); + } + + public function testFromArrayWithValidData(): void + { + $data = [ + 'p50' => 12.5, + 'p80' => 16.0, + 'p90' => 20.0, + 'p95' => 25.0, + 'p99' => 35.0, + ]; + + $metrics = PercentileMetrics::fromArray($data); + + self::assertSame(12.5, $metrics->p50); + self::assertSame(16.0, $metrics->p80); + self::assertSame(20.0, $metrics->p90); + self::assertSame(25.0, $metrics->p95); + self::assertSame(35.0, $metrics->p99); + } + + public function testFromArrayWithMissingKeysDefaultsToZero(): void + { + $data = []; + + $metrics = PercentileMetrics::fromArray($data); + + self::assertSame(0.0, $metrics->p50); + self::assertSame(0.0, $metrics->p80); + self::assertSame(0.0, $metrics->p90); + self::assertSame(0.0, $metrics->p95); + self::assertSame(0.0, $metrics->p99); + } + + public function testFromArrayWithPartialDataDefaultsMissingToZero(): void + { + $data = [ + 'p50' => 10.0, + 'p90' => 20.0, + ]; + + $metrics = PercentileMetrics::fromArray($data); + + self::assertSame(10.0, $metrics->p50); + self::assertSame(0.0, $metrics->p80); + self::assertSame(20.0, $metrics->p90); + self::assertSame(0.0, $metrics->p95); + self::assertSame(0.0, $metrics->p99); + } + + public function testFromArrayWithExtraKeysIgnoresThem(): void + { + $data = [ + 'p50' => 10.0, + 'p80' => 15.0, + 'p90' => 20.0, + 'p95' => 25.0, + 'p99' => 30.0, + 'p100' => 40.0, + 'extra' => 100.0, + ]; + + $metrics = PercentileMetrics::fromArray($data); + + self::assertSame(10.0, $metrics->p50); + self::assertSame(15.0, $metrics->p80); + self::assertSame(20.0, $metrics->p90); + self::assertSame(25.0, $metrics->p95); + self::assertSame(30.0, $metrics->p99); + } + + public function testValueObjectIsReadonly(): void + { + $metrics = new PercentileMetrics( + p50: 10.0, + p80: 15.0, + p90: 20.0, + p95: 25.0, + p99: 30.0, + ); + + $reflection = new ReflectionClass($metrics); + self::assertTrue($reflection->isReadOnly()); + } + + public function testPercentilesAreInAscendingOrder(): void + { + $metrics = new PercentileMetrics( + p50: 10.0, + p80: 15.0, + p90: 20.0, + p95: 25.0, + p99: 30.0, + ); + + // Verify semantic meaning: higher percentiles should have higher or equal values + self::assertLessThanOrEqual($metrics->p80, $metrics->p50); + self::assertLessThanOrEqual($metrics->p90, $metrics->p80); + self::assertLessThanOrEqual($metrics->p95, $metrics->p90); + self::assertLessThanOrEqual($metrics->p99, $metrics->p95); + } +} diff --git a/tests/Unit/Domain/Dashboard/Service/StatisticsCalculatorTest.php b/tests/Unit/Domain/Dashboard/Service/StatisticsCalculatorTest.php new file mode 100644 index 0000000..a307f2a --- /dev/null +++ b/tests/Unit/Domain/Dashboard/Service/StatisticsCalculatorTest.php @@ -0,0 +1,198 @@ +calculator = new StatisticsCalculator(); + } + + public function testCalculateWithEmptyMetricsReturnsZeroStatistics(): void + { + $metrics = new BenchmarkMetrics( + benchmarkId: 'test-bench', + benchmarkName: 'Test Benchmark', + phpVersion: 'php84', + executionTimes: [], + memoryUsages: [], + memoryPeaks: [], + ); + + $statistics = $this->calculator->calculate($metrics); + + self::assertSame('test-bench', $statistics->benchmarkId); + self::assertSame('Test Benchmark', $statistics->benchmarkName); + self::assertSame('php84', $statistics->phpVersion); + self::assertSame(0, $statistics->executionCount); + self::assertSame(0.0, $statistics->averageExecutionTime); + self::assertSame(0.0, $statistics->averageMemoryUsed); + self::assertSame(0.0, $statistics->peakMemoryUsed); + self::assertSame(0.0, $statistics->percentiles->p50); + self::assertSame(0.0, $statistics->percentiles->p90); + self::assertSame(0.0, $statistics->percentiles->p99); + } + + public function testCalculateWithSingleValueReturnsCorrectStatistics(): void + { + $metrics = new BenchmarkMetrics( + benchmarkId: 'test-bench', + benchmarkName: 'Test Benchmark', + phpVersion: 'php84', + executionTimes: [10.5], + memoryUsages: [1024.0], + memoryPeaks: [2048.0], + ); + + $statistics = $this->calculator->calculate($metrics); + + self::assertSame(1, $statistics->executionCount); + self::assertSame(10.5, $statistics->averageExecutionTime); + self::assertSame(1024.0, $statistics->averageMemoryUsed); + self::assertSame(2048.0, $statistics->peakMemoryUsed); + self::assertSame(10.5, $statistics->percentiles->p50); + self::assertSame(10.5, $statistics->percentiles->p90); + self::assertSame(10.5, $statistics->percentiles->p99); + } + + public function testCalculateWithMultipleValuesReturnsCorrectAverage(): void + { + $metrics = new BenchmarkMetrics( + benchmarkId: 'test-bench', + benchmarkName: 'Test Benchmark', + phpVersion: 'php84', + executionTimes: [10.0, 20.0, 30.0], + memoryUsages: [100.0, 200.0, 300.0], + memoryPeaks: [150.0, 250.0, 350.0], + ); + + $statistics = $this->calculator->calculate($metrics); + + self::assertSame(3, $statistics->executionCount); + self::assertSame(20.0, $statistics->averageExecutionTime); + self::assertSame(200.0, $statistics->averageMemoryUsed); + self::assertSame(350.0, $statistics->peakMemoryUsed); + } + + public function testCalculatePercentilesWithSortedData(): void + { + // Dataset: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 (10 values) + $metrics = new BenchmarkMetrics( + benchmarkId: 'test-bench', + benchmarkName: 'Test Benchmark', + phpVersion: 'php84', + executionTimes: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0], + memoryUsages: [100.0], + memoryPeaks: [100.0], + ); + + $statistics = $this->calculator->calculate($metrics); + + // P50 (50th percentile / median) = 5th value + self::assertSame(5.0, $statistics->percentiles->p50); + + // P80 (80th percentile) = 8th value + self::assertSame(8.0, $statistics->percentiles->p80); + + // P90 (90th percentile) = 9th value + self::assertSame(9.0, $statistics->percentiles->p90); + + // P95 (95th percentile) = 10th value + self::assertSame(10.0, $statistics->percentiles->p95); + + // P99 (99th percentile) = 10th value (last) + self::assertSame(10.0, $statistics->percentiles->p99); + } + + public function testCalculatePercentilesWithUnsortedData(): void + { + // Unsorted dataset: should be sorted internally + $metrics = new BenchmarkMetrics( + benchmarkId: 'test-bench', + benchmarkName: 'Test Benchmark', + phpVersion: 'php84', + executionTimes: [50.0, 10.0, 30.0, 20.0, 40.0], + memoryUsages: [100.0], + memoryPeaks: [100.0], + ); + + $statistics = $this->calculator->calculate($metrics); + + // After sorting: [10, 20, 30, 40, 50] + // P50 (median) = 30 (3rd value) + self::assertSame(30.0, $statistics->percentiles->p50); + + // P90 = 50 (5th value, 90% of 5 = 4.5 -> ceil = 5) + self::assertSame(50.0, $statistics->percentiles->p90); + } + + public function testCalculateWithRealWorldBenchmarkData(): void + { + // Realistic benchmark execution times in milliseconds + $metrics = new BenchmarkMetrics( + benchmarkId: 'array-loop', + benchmarkName: 'Array Loop Performance', + phpVersion: 'php84', + executionTimes: [ + 12.5, 12.8, 13.1, 12.9, 13.0, + 12.7, 12.6, 13.2, 12.8, 12.9, + 50.0, // outlier + ], + memoryUsages: [ + 1024.0, 1025.0, 1026.0, 1024.5, 1025.5, + 1024.8, 1025.2, 1026.1, 1025.3, 1024.9, + 2048.0, // outlier + ], + memoryPeaks: [ + 2048.0, 2049.0, 2050.0, 2048.5, 2049.5, + 2048.8, 2049.2, 2050.1, 2049.3, 2048.9, + 4096.0, // outlier + ], + ); + + $statistics = $this->calculator->calculate($metrics); + + self::assertSame(11, $statistics->executionCount); + + // Average should include the outlier + self::assertGreaterThan(13.0, $statistics->averageExecutionTime); + self::assertLessThan(20.0, $statistics->averageExecutionTime); + + // P50 should be around 12.9 (not affected by outlier) + self::assertGreaterThanOrEqual(12.5, $statistics->percentiles->p50); + self::assertLessThanOrEqual(13.2, $statistics->percentiles->p50); + + // P99 should be the outlier + self::assertSame(50.0, $statistics->percentiles->p99); + } + + public function testCalculateDoesNotModifyOriginalData(): void + { + $originalTimes = [30.0, 10.0, 20.0]; + $metrics = new BenchmarkMetrics( + benchmarkId: 'test-bench', + benchmarkName: 'Test Benchmark', + phpVersion: 'php84', + executionTimes: $originalTimes, + memoryUsages: [100.0], + memoryPeaks: [100.0], + ); + + $this->calculator->calculate($metrics); + + // Original array should remain unchanged (immutability check) + self::assertCount(3, $originalTimes); + self::assertSame(30.0, $originalTimes[0]); + self::assertSame(10.0, $originalTimes[1]); + self::assertSame(20.0, $originalTimes[2]); + } +} diff --git a/tests/Unit/Domain/PhpVersion/Enum/PhpVersionTest.php b/tests/Unit/Domain/PhpVersion/Enum/PhpVersionTest.php new file mode 100644 index 0000000..68cc62a --- /dev/null +++ b/tests/Unit/Domain/PhpVersion/Enum/PhpVersionTest.php @@ -0,0 +1,93 @@ +value); + self::assertSame('php70', PhpVersion::PHP_7_0->value); + self::assertSame('php71', PhpVersion::PHP_7_1->value); + self::assertSame('php72', PhpVersion::PHP_7_2->value); + self::assertSame('php73', PhpVersion::PHP_7_3->value); + self::assertSame('php74', PhpVersion::PHP_7_4->value); + self::assertSame('php80', PhpVersion::PHP_8_0->value); + self::assertSame('php81', PhpVersion::PHP_8_1->value); + self::assertSame('php82', PhpVersion::PHP_8_2->value); + self::assertSame('php83', PhpVersion::PHP_8_3->value); + self::assertSame('php84', PhpVersion::PHP_8_4->value); + self::assertSame('php85', PhpVersion::PHP_8_5->value); + } + + public function testFromValueCreatesCorrectEnum(): void + { + self::assertSame(PhpVersion::PHP_8_4, PhpVersion::from('php84')); + self::assertSame(PhpVersion::PHP_8_3, PhpVersion::from('php83')); + self::assertSame(PhpVersion::PHP_7_4, PhpVersion::from('php74')); + } + + public function testTryFromReturnsNullForInvalidValue(): void + { + self::assertNull(PhpVersion::tryFrom('php99')); + self::assertNull(PhpVersion::tryFrom('invalid')); + self::assertNull(PhpVersion::tryFrom('')); + } + + public function testTryFromReturnsEnumForValidValue(): void + { + self::assertSame(PhpVersion::PHP_8_4, PhpVersion::tryFrom('php84')); + self::assertSame(PhpVersion::PHP_5_6, PhpVersion::tryFrom('php56')); + } + + public function testEnumIsBackedByString(): void + { + $enum = PhpVersion::PHP_8_4; + + self::assertIsString($enum->value); + } + + public function testEnumCanBeComparedInConditionals(): void + { + $version = PhpVersion::PHP_8_4; + + $isModern = match (true) { + PhpVersion::PHP_8_3 === $version || PhpVersion::PHP_8_4 === $version => true, + default => false, + }; + + self::assertTrue($isModern); + } + + public function testEnumCasesAreSingleton(): void + { + $version1 = PhpVersion::PHP_8_4; + $version2 = PhpVersion::from('php84'); + + self::assertSame($version1, $version2); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e494c79..838b35f 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -7,7 +7,7 @@ require dirname(__DIR__) . '/vendor/autoload.php'; if (method_exists(Dotenv::class, 'bootEnv')) { - (new Dotenv())->bootEnv(dirname(__DIR__) . '/.env'); + new Dotenv()->bootEnv(dirname(__DIR__) . '/.env'); } if ($_SERVER['APP_DEBUG']) {