Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cc82b18
feat: add benchmark command and CI workflow with increased PHPStan level
fr3on Apr 5, 2026
5c71e1a
feat: add PR checklist validation to CI workflow
fr3on Apr 5, 2026
6352b22
fix: replace broken PR checklist action with customized github-script
fr3on Apr 5, 2026
eee1e49
feat: improve CI PR validation to enforce checklist completion and ty…
fr3on Apr 5, 2026
6f95095
chore: downgrade PR template validation errors to warnings and skip c…
fr3on Apr 5, 2026
e8ffd4f
feat: add automated benchmark reporting to PRs, include server logs o…
fr3on Apr 5, 2026
3c4cc81
refactor: use FFI instance instead of static Runtime call for callbac…
fr3on Apr 5, 2026
54fd073
refactor: migrate benchmark results from PR body to dedicated issue c…
fr3on Apr 5, 2026
119f1d4
chore: add FFI environment debugging to CI and implement runtime chec…
fr3on Apr 5, 2026
f64fa60
chore: refactor FFI debug commands to use multi-line syntax in CI wor…
fr3on Apr 5, 2026
fa99a88
chore: add FFI environment debugging to CI and suppress PHPStan error…
fr3on Apr 5, 2026
bddd382
refactor: remove FFI environment check and update CI to use pre-built…
fr3on Apr 5, 2026
a0fda1a
feat: implement SocketServer fallback for environments without FFI::c…
fr3on Apr 5, 2026
af1b851
feat: update benchmark reporting format and add support for --port fl…
fr3on Apr 5, 2026
748bd89
feat: add FFI typedefs, implement router recompilation for worker pro…
fr3on Apr 5, 2026
1ad4b62
chore: bump version to 0.0.2 and improve type annotations for Validat…
fr3on Apr 5, 2026
25d4021
chore: fetch latest quill-core version in CI and add fallback for pre…
fr3on Apr 5, 2026
7700035
ci: include core version and worker count in benchmark performance re…
fr3on Apr 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 140 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
});
}
163 changes: 128 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,127 @@
<h1>QuillPHP</h1>
<p><strong>High-performance PHP 8.3+ API framework — boot once, serve forever.</strong></p>

[![CI](https://github.com/quillphp/quill/actions/workflows/ci.yml/badge.svg)](https://github.com/quillphp/quill/actions/workflows/ci.yml)
[![Benchmark](https://github.com/quillphp/quill/actions/workflows/benchmark.yml/badge.svg)](https://github.com/quillphp/quill/actions/workflows/benchmark.yml)
[![PHP](https://img.shields.io/badge/php-%5E8.3-777bb4.svg)](https://php.net)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![CI](https://github.com/quillphp/quill/actions/workflows/ci.yml/badge.svg)](https://github.com/quillphp/quill/actions/workflows/ci.yml)
[![PHP](https://img.shields.io/badge/php-%5E8.3-777bb4.svg)](https://php.net)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

### [Documentation](https://quillphp.github.io/quill) &bull; [Quick Start](.github/docs/getting-started.md) &bull; [Benchmarks](.github/docs/benchmarks.md)
### [Quick Start](.github/docs/getting-started.md) &bull; [Benchmarks](.github/docs/benchmarks.md)
</div>

---

## 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&section=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.

---

Expand All @@ -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.

---

Expand Down
7 changes: 5 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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": {
Expand Down
Loading
Loading