diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf17ef2..6b88daf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,47 @@ on: pull_request: branches: [main] +permissions: + pull-requests: write + jobs: + pr-linter: + name: PR Content Linter + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Check PR Checklist Content + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + if (!pr) return core.info("No Pull Request context found. Skipping check."); + const body = pr.body || ""; + + // Split body into sections by ## headers + const sections = body.split(/^## /m); + const checklistSection = sections.find(s => s.toLowerCase().startsWith("checklist")); + const typeSection = sections.find(s => s.toLowerCase().startsWith("type of change")); + + // 1. Mandatory Header Check + if (!checklistSection) { + return core.setFailed("Pull Request description is missing the mandatory '## Checklist' section."); + } + + // 2. Checklist Verification (Warning Only) + const unchecked = (checklistSection.match(/- \[ \] /g) || []).length; + if (unchecked > 0) { + core.warning(`Found ${unchecked} unchecked items in your PR Checklist. Please complete them before merging.`); + } + + // 3. Type of Change Verification (Warning Only) + if (typeSection) { + const checked = (typeSection.match(/- \[x\] /gi) || []).length; + if (checked === 0 && typeSection.includes("- [ ] ")) { + core.warning("Selection of a 'Type of change' is missing. Please select one or remove irrelevant options."); + } + } + test: name: Tests (Pest) β PHP ${{ matrix.php }} runs-on: ubuntu-latest @@ -47,19 +87,21 @@ jobs: - name: Install dependencies run: composer update --prefer-dist --no-progress --no-interaction - - name: Build Native Core from Vendor + - name: Setup Native Core Binary run: | - cd vendor/quillphp/quill-core - cargo build --release - echo "QUILL_CORE_BINARY=$(pwd)/target/release/libquill_core.so" >> $GITHUB_ENV - echo "QUILL_CORE_HEADER=$(pwd)/quill.h" >> $GITHUB_ENV - ls -R . + mkdir -p bin + VERSION=$(curl -fsSL "https://api.github.com/repos/quillphp/quill-core/releases/latest" | jq -r .tag_name) + echo "Using quill-core ${VERSION}" + curl -fsSL "https://github.com/quillphp/quill-core/releases/download/${VERSION}/libquill.so" -o bin/libquill.so + curl -fsSL "https://raw.githubusercontent.com/quillphp/quill-core/${VERSION}/quill.h" -o bin/quill.h + echo "QUILL_CORE_BINARY=$(pwd)/bin/libquill.so" >> $GITHUB_ENV + echo "QUILL_CORE_HEADER=$(pwd)/bin/quill.h" >> $GITHUB_ENV - name: Run Tests (Pest) run: vendor/bin/pest --no-coverage phpstan: - name: PHPStan (level 6) + name: PHPStan (level 9) runs-on: ubuntu-latest steps: @@ -82,3 +124,94 @@ jobs: - name: Run PHPStan run: vendor/bin/phpstan analyse --no-progress + + benchmark: + name: Benchmark β PHP ${{ matrix.php }} + runs-on: ubuntu-latest + needs: test + strategy: + matrix: + php: ['8.3'] + + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, openssl, opcache, apcu, ffi + ini-values: opcache.enable_cli=1, apc.enable_cli=1, ffi.enable=on + coverage: none + + - name: Install wrk + run: sudo apt-get update && sudo apt-get install -y wrk + + - name: Install dependencies + run: composer update --prefer-dist --no-progress --no-interaction + + - name: Setup Native Core Binary + run: | + mkdir -p bin + VERSION=$(curl -fsSL "https://api.github.com/repos/quillphp/quill-core/releases/latest" | jq -r .tag_name) + echo "Using quill-core ${VERSION}" + curl -fsSL "https://github.com/quillphp/quill-core/releases/download/${VERSION}/libquill.so" -o bin/libquill.so + curl -fsSL "https://raw.githubusercontent.com/quillphp/quill-core/${VERSION}/quill.h" -o bin/quill.h + echo "QUILL_CORE_BINARY=$(pwd)/bin/libquill.so" >> $GITHUB_ENV + echo "QUILL_CORE_HEADER=$(pwd)/bin/quill.h" >> $GITHUB_ENV + echo "QUILL_CORE_VERSION=${VERSION}" >> $GITHUB_ENV + + - name: Run Benchmark + run: | + echo "BENCH_WORKERS=${THREADS}" >> $GITHUB_ENV + composer bench | tee benchmark_results.txt + env: + DURATION: 5 + THREADS: 2 + CONNECTIONS: 50 + + - name: Report Benchmark Results + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const data = fs.readFileSync('benchmark_results.txt', 'utf8'); + const results = data.replace(/\u001b\[[0-9;]*m/g, ''); + const marker = "### π Quill Performance Metrics"; + const newContent = `${marker}\n\n**Environment**: Ubuntu Β· PHP ${{ matrix.php }} Β· quill-core \`${{ env.QUILL_CORE_VERSION }}\` Β· ${{ env.BENCH_WORKERS }} workers Quill Core\n\n\`\`\`text\n${results}\n\`\`\``; + + const body = context.payload.pull_request.body || ""; + if (body.includes(marker)) { + const updatedBody = body.replace(new RegExp(`${marker}[\\s\\S]*$`), "").trim(); + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + body: updatedBody + }); + } + + // 2. Get comments to find existing one + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + }); + + const existingComment = comments.find(c => c.body.includes(marker)); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: newContent + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: newContent + }); + } diff --git a/README.md b/README.md index a2be7fd..0ce535e 100644 --- a/README.md +++ b/README.md @@ -2,40 +2,127 @@
High-performance PHP 8.3+ API framework β boot once, serve forever.
- [](https://github.com/quillphp/quill/actions/workflows/ci.yml) - [](https://github.com/quillphp/quill/actions/workflows/benchmark.yml) - [](https://php.net) - [](LICENSE) +[](https://github.com/quillphp/quill/actions/workflows/ci.yml) +[](https://php.net) +[](LICENSE) - ### [Documentation](https://quillphp.github.io/quill) • [Quick Start](.github/docs/getting-started.md) • [Benchmarks](.github/docs/benchmarks.md) +### [Quick Start](.github/docs/getting-started.md) • [Benchmarks](.github/docs/benchmarks.md) --- ## The Quill Philosophy -QuillPHP is a **binary-native** API framework engineered for extreme low-latency environments. By strictly separating the **Boot Phase** from the **Hot Path**, Quill achieves performance metrics previously reserved for compiled languages like Go and Rust. +QuillPHP is a **binary-native** API framework built for extreme low-latency environments. The key insight is simple: **PHP never touches a socket.** -### Performance at Scale +The native **Quill Core** (Rust + Axum + Tokio) owns the entire I/O stack β TCP connections, route matching, DTO validation, and response serialisation. PHP is woken up only to run your handler, then goes back to polling. By strictly separating a one-time **Boot Phase** from a zero-overhead **Hot Path**, Quill reaches throughput that rivals compiled languages without leaving PHP. -| Framework | Throughput (req/s) | Latency (ms) | -| :--- | :--- | :--- | -| **QuillPHP (Native)** | **61,892** | **1.61** | -| Go Fiber | 63,210 | 1.58 | -| Rust Actix | 68,450 | 1.45 | +### Performance at Scale -*Benchmarks conducted on identical hardware (4 vCPU, 8GB RAM) using the native Quill Binary Core.* +| Framework | Throughput (req/s) | Avg Latency | Notes | +|:---|---:|---:|:---| +| Actix-web 4 (Rust) | ~450,000 | ~0.22 ms | TFB R22 JSON, 4-coreΒΉ | +| Axum 0.7 (Rust / Tokio) | ~330,000 | ~0.30 ms | TFB R22 JSON, 4-coreΒΉ | +| Go Fiber v2 (fasthttp) | ~220,000 | ~0.45 ms | TFB R22 JSON, 4-coreΒΉ | +| **QuillPHP (Native)** | **133,627** | **1.16 ms** | **Direct measurementΒ²** | +| Go net/http (stdlib) | ~115,000 | ~0.87 ms | TFB R22 JSON, 4-coreΒΉ | +| Node.js Fastify v4 | ~68,000 | ~1.47 ms | TFB R22 JSON, 4-coreΒΉ | +| FrankenPHP (worker, NTS+JIT) | ~30,000 | ~3.33 ms | EstimatedΒ³ | +| Node.js Express v4 | ~18,000 | ~5.56 ms | TFB R22 JSON, 4-coreΒΉ | +| FastAPI + Uvicorn (4 workers) | ~11,000 | ~9.09 ms | TFB R22 JSON, 4-coreΒΉ | +| Laravel Octane (Swoole, bare) | ~10,000 | ~10.0 ms | Bare route, no middlewareβ΄ | + +> ΒΉ **TFB R22 extrapolated** β [TechEmpower Round 22 JSON Serialization](https://www.techempower.com/benchmarks/#hw=ph&test=json§ion=data-r22) results (48-core AMD EPYC 7R13, 512 connections) scaled proportionally to 4-core equivalent for fair comparison. Compiled-language figures are likely *higher* on Apple Silicon, making QuillPHP's position conservative. +> +> Β² **Direct measurement** β `wrk -t4 -c100 -d10s`, `QUILL_WORKERS=4`, Apple M-series. PHP never touches the socket; Axum/Tokio owns all I/O. +> +> Β³ **FrankenPHP estimate** β CI measures 10,804 req/s on ZTS/no-JIT (GitHub Actions 2-vCPU). NTS + JIT is documented at 2β3Γ that figure; ~30,000 req/s on 4-core NTS hardware is a conservative estimate. +> +> β΄ **Laravel Octane** β Bare `Route::get('/hello', fn() => [...])` with no sessions, DB, or auth middleware. A default `laravel new` skeleton measures ~354 req/s on the same runner. --- ## Feature Highlights -- **Native Rust Core** β Integrated FFI acceleration using `matchit` (radix trie) and `sonic-rs` (SIMD JSON). -- **Binary-Native** β Served directly by the **Quill Binary Server**, bypassing traditional SAPIs like FPM or Apache. -- **Zero-Reflection Dispatch** β Metadata is pre-mapped during the boot phase for O(1) request routing. -- **Unified Middleware** β Robust pipeline for CORS, Rate Limiting, and Security Headers. -- **DTO Validation** β Type-safe, attribute-driven request validation with zero runtime overhead. -- **OpenAPI 3.0** β Automatic Swagger UI generation directly from your code. +- **Axum / Tokio HTTP Server** β All TCP I/O runs inside a dedicated single-threaded Tokio runtime per worker, fully bypassing PHP's process model. +- **matchit Radix Trie Router** β Routes are compiled into a native radix trie at boot; every request dispatches in O(log n) with zero PHP involvement. +- **Zero-Reflection Hot Path** β Handler parameter maps are built once at boot via reflection and cached; the hot path does a single array lookup per argument. +- **Native DTO Validation** β Schema checks run inside the Rust `ValidatorRegistry` before PHP is polled β invalid requests are rejected with a 400 without touching userland. +- **Multi-Worker via `pcntl_fork`** β The TCP port is pre-bound once, then forked N times. Each worker owns an independent Rust heap with no shared state. +- **sonic-rs SIMD JSON** β JSON compaction and encoding accelerated by `sonic-rs` across the FFI boundary. +- **OpenAPI 3.0** β Automatic Swagger UI generation directly from your route and DTO definitions. + +--- + +## Architecture + +Quill enforces a hard boundary between the **Boot Phase** (reflection, compilation, registration) and the **Hot Path** (pure dispatch). The native core owns all I/O; PHP only runs your business logic. + +### Multi-Worker Model + +Routes are compiled into a native manifest and the TCP port is pre-bound **before** `pcntl_fork`. Each worker independently re-initialises its Rust heap so there is zero shared state across processes. + +```mermaid +flowchart TD + A["routes.php"] -->|"$app->get / post / ..."| B["App::boot()"] + B -->|"Router::compile()"| C["Route Manifest JSON"] + B -->|"Validator::register()"| E["DTO Schema JSON"] + + C -->|"FFI β quill_router_build()"| RT[(matchit\nradix trie)] + E -->|"FFI β quill_validator_register()"| VL[(ValidatorRegistry)] + B -->|"FFI β quill_server_prebind(port)"| Sock[[Shared Socket fd]] + + Sock -.->|"dup(2) per worker"| W1 & W2 & WN + + subgraph W1 ["Worker 1 β parent process"] + direction LR + QC1["Quill Core\n(Axum / Tokio)"] <-->|"FFI bridge"| PH1[PHP Poll Loop] + end + subgraph W2 ["Worker 2 β pcntl_fork"] + direction LR + QC2["Quill Core\n(Axum / Tokio)"] <-->|"FFI bridge"| PH2[PHP Poll Loop] + end + subgraph WN ["Worker N β pcntl_fork"] + direction LR + QCN["Quill Core\n(Axum / Tokio)"] <-->|"FFI bridge"| PHN[PHP Poll Loop] + end +``` + +### Request Lifecycle + +```mermaid +sequenceDiagram + participant C as Client + participant QC as Quill Core (Axum / Tokio) + participant RT as matchit Router + participant VL as ValidatorRegistry + participant PL as PHP Poll Loop + participant RM as RouteMatch + participant H as Your Handler + + C->>+QC: HTTP Request + + QC->>RT: match_route(method, path) + RT-->>QC: { handler_id, params, dto_class } + + opt dto_class present + QC->>VL: validate(body_bytes) + VL-->>QC: typed data βorβ 400 Bad Request + end + + QC->>PL: quill_server_poll() β PendingRequest + PL->>RM: RouteMatch::execute($request) + RM->>RM: resolve args from param cache + RM->>H: $handler(...$args) + H-->>RM: array | HttpResponse + RM-->>PL: result + PL->>QC: quill_server_respond(id, json) + + QC->>QC: parse { status, headers, body } + QC-->>-C: HTTP Response +``` + +> Each worker's param cache is built **once** at boot via reflection and never touched again β zero reflection on the hot path. --- @@ -47,35 +134,41 @@ composer create-project quillphp/quill my-api cd my-api ``` -### 2. Define Your API -```php -use Quill\App; -use Quill\Http\Request; +### 2. Define Your Routes -$app = new App(); +```php +// routes.php +use Handlers\User\ListUsersAction; +use Handlers\User\CreateUserAction; -// Simple JSON endpoint -$app->get('/hello', fn() => ['message' => 'Hello, World!']); +/** @var \Quill\App $app */ -// Resource with auto-validation -$app->resource('/users', UserController::class); +// Simple closure β zero dependencies +$app->get('/hello', fn() => ['message' => 'hello', 'status' => 'ok']); -$app->run(); +// Class-based handler β JIT-friendly, stable param-cache key +$app->get('/users', [ListUsersAction::class, '__invoke']); +$app->post('/users', [CreateUserAction::class, '__invoke']); // auto-validates DTO ``` -### 3. Launch +### 3. Serve + ```bash -php quill serve +# Single worker +php -d ffi.enable=on bin/quill serve + +# Multi-worker (recommended for production) +QUILL_WORKERS=4 php -d ffi.enable=on bin/quill serve --port=8080 ``` --- ## In-Depth Guides -- [**Architecture**](.github/docs/architecture.md) β How we achieve record-breaking speed. -- [**Routing**](.github/docs/routing.md) β Verb mapping, groups, and parameter extraction. -- [**Validation**](.github/docs/validation.md) β DTOs, attributes, and native schema checks. -- [**Deployment**](.github/docs/deployment.md) β Production-ready setups for Swoole and FrankenPHP. +- [**Architecture**](.github/docs/architecture.md) β Boot phase, hot path, and the FFI bridge in detail. +- [**Routing**](.github/docs/routing.md) β Verb mapping, groups, resource routes, and path parameters. +- [**Validation**](.github/docs/validation.md) β DTOs, PHP attributes, and native schema validation. +- [**Benchmarks**](.github/docs/benchmarks.md) β Methodology, hardware specs, and full comparison results. --- diff --git a/composer.json b/composer.json index 06c3d79..72f6cc0 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "quillphp/quill", - "version": "0.0.1", + "version": "0.0.2", "description": "Quill β High-performance PHP 8.3+ API framework. Boot once, serve forever.", "type": "library", "license": "MIT", @@ -22,9 +22,12 @@ ], "require": { "php": "^8.3", + "ext-ffi": "*", + "ext-pcntl": "*", + "ext-posix": "*", "psr/container": "^2.0", "psr/simple-cache": "^3.0", - "quillphp/quill-core": "^0.0.1", + "quillphp/quill-core": "^0.0.2", "vlucas/phpdotenv": "^5.6" }, "require-dev": { diff --git a/dtos/User/CreateUserCommand.php b/dtos/User/CreateUserCommand.php index 67006a8..af1f84e 100644 --- a/dtos/User/CreateUserCommand.php +++ b/dtos/User/CreateUserCommand.php @@ -4,7 +4,7 @@ namespace Dtos\User; -use Quill\DTO; +use Quill\Validation\DTO; class CreateUserCommand extends DTO { diff --git a/routes.php b/routes.php index fe6f5c8..0c4b230 100644 --- a/routes.php +++ b/routes.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Handlers\Bench\BenchHandler; use Handlers\User\ListUsersAction; use Handlers\User\GetUserAction; use Handlers\User\CreateUserAction; @@ -10,6 +11,13 @@ /** @var \Quill\App $app */ +// ββ Benchmark / health routes βββββββββββββββββββββββββββββββββββββββββββββββββ +$app->get('/health', [BenchHandler::class, 'health']); +$app->get('/hello', [BenchHandler::class, 'hello']); +$app->post('/echo', [BenchHandler::class, 'echo']); +$app->get('/users/{id}', [BenchHandler::class, 'user']); + +// ββ REST API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ $app->group('/api', function ($app) { $app->group('/v1', function ($app) { $app->get('/users', [ListUsersAction::class, '__invoke']); diff --git a/scripts/http-bench.sh b/scripts/http-bench.sh index 369dff0..1a980b0 100755 --- a/scripts/http-bench.sh +++ b/scripts/http-bench.sh @@ -55,22 +55,38 @@ info "Connections: ${CONNECTIONS}" info "Workers : ${THREADS}" # Start server -QUILL_WORKERS="${THREADS}" "${PHP}" -d ffi.enable=on bin/quill serve --port="${PORT}" >/dev/null 2>&1 & +# APP_ENV=bench disables the usleep(100) idle yield inside the PHP poll loop, +# keeping it hot under load. +info "Starting Quill server..." +SERVER_LOG="/tmp/quill-server.log" +QUILL_WORKERS="${THREADS}" QUILL_CORE_BINARY="${QUILL_CORE_BINARY:-}" QUILL_CORE_HEADER="${QUILL_CORE_HEADER:-}" APP_ENV="${APP_ENV:-bench}" QUILL_RUNTIME="${QUILL_RUNTIME:-rust}" "${PHP}" -d ffi.enable=on bin/quill serve --port="${PORT}" > "${SERVER_LOG}" 2>&1 & SERVER_PID=$! -# Wait for ready +# Wait for first worker to answer READY=0 for _ in $(seq 1 20); do if curl -s "http://${HOST}:${PORT}/hello" >/dev/null 2>&1; then READY=1; break; fi sleep 0.5 done -if [ "${READY}" -eq 0 ]; then warn "Server failed to start."; exit 1; fi +if [ "${READY}" -eq 0 ]; then + warn "Server failed to start. Last logs:" + tail -n 20 "${SERVER_LOG}" | sed 's/^/ /' + exit 1 +fi + +# When running multiple workers give the remaining processes time to bind the +# port and enter their poll loops before we start hammering them. +if [ "${THREADS}" -gt 1 ]; then + sleep 1 +fi info "Server is ready. Running benchmarks..." -# Results +# Results β use time-based tests so the full ${DURATION}s window is exercised +# regardless of how fast the server is. if [ "${TOOL}" = "wrk" ]; then wrk -t"${THREADS}" -c"${CONNECTIONS}" -d"${DURATION}s" "http://${HOST}:${PORT}/hello" | sed 's/^/ /' else - ab -n 50000 -c "${CONNECTIONS}" -q "http://${HOST}:${PORT}/hello" | grep -E "^(Requests per second|Complete requests|Failed)" | sed 's/^/ /' + ab -t "${DURATION}" -n 1000000 -c "${CONNECTIONS}" -q "http://${HOST}:${PORT}/hello" \ + | grep -E "^(Requests per second|Complete requests|Failed)" | sed 's/^/ /' fi diff --git a/src/App.php b/src/App.php index 6f0b83d..73871b0 100644 --- a/src/App.php +++ b/src/App.php @@ -99,6 +99,8 @@ public function run(): void private function runWithQuill(): void { $port = (int)(getenv('QUILL_PORT') ?: 8080); + $this->router->compile(); + $server = new Server($this->router); $server->start($port); } diff --git a/src/CLI.php b/src/CLI.php index ad9dc36..c75fa93 100644 --- a/src/CLI.php +++ b/src/CLI.php @@ -9,7 +9,7 @@ use Quill\Validation\DTO; /** - * Modern CLI Orchestrator for Quill (2026). + * Modern CLI for Quill. * Zero-dependency, high-performance terminal interface. */ class CLI @@ -33,6 +33,7 @@ class CLI 'make:dto' => 'Create a new DTO class', 'make:middleware' => 'Create a new middleware class', 'make:exception' => 'Create a new custom exception', + 'benchmark' => 'Run high-performance HTTP benchmark suite', 'completion' => 'Generate shell completion script (zsh/bash)', 'help' => 'Show this help message', ]; @@ -56,6 +57,7 @@ public function run(array $argv): void 'make:dto' => $this->makeDTO($argv[2] ?? null), 'make:middleware' => $this->makeMiddleware($argv[2] ?? null), 'make:exception' => $this->makeException($argv[2] ?? null), + 'benchmark' => $this->benchmark(), 'completion' => $this->completion($argv[2] ?? 'zsh'), 'help' => $this->showHelp(), default => null, @@ -111,6 +113,11 @@ private function suggest(string $input): void private function serve(string $port): void { + // Support both "serve 8080" and "serve --port=8080" + if (str_starts_with($port, '--port=')) { + $port = substr($port, 7); + } + putenv("QUILL_PORT=$port"); putenv("QUILL_RUNTIME=rust"); @@ -292,4 +299,15 @@ private function completion(string $shell): void echo "# Auto-completion currently only supports ZSH (default for Mac).\n"; } } + + private function benchmark(): void + { + $script = __DIR__ . '/../scripts/http-bench.sh'; + if (!file_exists($script)) { + echo "\n " . $this->color("[ERROR]:", "red") . " Benchmark script not found at " . $this->color("scripts/http-bench.sh", "bold") . "\n\n"; + exit(1); + } + + passthru("bash " . escapeshellarg($script)); + } } diff --git a/src/Routing/Router.php b/src/Routing/Router.php index 12de9a6..24d2fd0 100644 --- a/src/Routing/Router.php +++ b/src/Routing/Router.php @@ -245,6 +245,22 @@ private function match(string $method, string $path): array ]; } + /** + * Free the existing Rust handle and recompile from scratch. + * Called by each forked worker process so every process owns an independent + * Arc