Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
149 changes: 33 additions & 116 deletions .github/docs/deployment.md
Original file line number Diff line number Diff line change
@@ -1,145 +1,62 @@
# Deployment Guide
# Deployment & Multi-worker Architecture

QuillPHP runs as a self-contained long-running process. No web server (Apache, Nginx, PHP-FPM) is required.
QuillPHP is designed for ultra-high performance using a binary-first core and a multi-worker process model. This document outlines the requirements and best practices for deploying QuillPHP in production.

---
## System Requirements

## 1. Prerequisites
To use the high-performance binary runtime and multi-worker features, your environment must have the following PHP extensions enabled:

- PHP 8.3+ CLI with `ffi`, `pcntl`, `posix` extensions enabled
- `ffi.enable=on` in `php.ini` (or pass `-d ffi.enable=on` on the command line)
- `libquill.so` / `libquill.dylib` from `quillphp/quill-core` (installed via Composer)

---

## 2. Start the Server

```bash
# Single worker
php bin/quill serve --port=8080

# Multiple workers (recommended for production)
QUILL_WORKERS=8 php bin/quill serve --port=8080
```

The process prints one line per worker when ready:

```
[Worker 12345] listening on http://0.0.0.0:8080
[Worker 12346] listening on http://0.0.0.0:8080
```

Each worker binds the port independently via `SO_REUSEPORT`; the kernel distributes connections evenly between them.

---

## 3. Recommended Worker Count

Set `QUILL_WORKERS` to the number of available CPU cores:

```bash
QUILL_WORKERS=$(nproc) php bin/quill serve --port=8080
```

---

## 4. OPcache Tuning

Enable OPcache for maximum PHP throughput:

```ini
; php.ini
opcache.enable=1
opcache.enable_cli=1
opcache.jit=tracing
opcache.jit_buffer_size=128M
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
```

---

## 5. Docker

```dockerfile
FROM php:8.3-cli-alpine

RUN apk add --no-cache libffi-dev \
&& docker-php-ext-configure ffi \
&& docker-php-ext-install ffi pcntl posix \
&& echo "ffi.enable=on" >> /usr/local/etc/php/php.ini

WORKDIR /var/www
COPY . .

RUN composer install --no-dev --optimize-autoloader

CMD ["php", "bin/quill", "serve", "--port=80"]
```

Run with workers matching vCPU count:
- **ext-ffi**: Required for communication with the native Quill Core (`libquill`).
- **ext-pcntl**: Required for process forking and signal management.
- **ext-posix**: Required for advanced process control.

### Installation Check
You can verify your environment by running:
```bash
docker run -e QUILL_WORKERS=4 -p 8080:80 my-quill-api
php -m | grep -E "ffi|pcntl|posix"
```

---
## Multi-worker Architecture

## 6. Reverse Proxy (Nginx / Traefik)
QuillPHP uses a "pre-fork" model. The parent process binds to the TCP port and then spawns multiple worker processes (configured via `QUILL_WORKERS`).

For TLS termination and global rate limiting, place Quill behind Nginx:
### Fork Safety
> [!IMPORTANT]
> Because workers are created via `pcntl_fork()`, they inherit the memory state of the parent. QuillPHP includes built-in guards to ensure that native handles (like the Router and Validator) are safely re-initialized in each child process to prevent memory corruption or double-free errors.

```nginx
upstream quill {
server 127.0.0.1:8080;
keepalive 64;
}
### PHP-FPM / CGI Warning
> [!CAUTION]
> **Do not set `QUILL_WORKERS > 1` when running under PHP-FPM or CGI.**
> Forking processes from within a web server environment like FPM is unsafe and can lead to deadlocks or leaked resources. Multi-worker mode is strictly intended for standalone CLI execution via `bin/quill serve`.

server {
listen 443 ssl http2;
server_name api.example.com;

location / {
proxy_pass http://quill;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
## Process Management

---
QuillPHP does not currently include a process watchdog. If a worker process dies, it will not be automatically respawned by the parent.

## 7. Process Management (systemd)
### Recommended: systemd
For production deployments, we recommend using `systemd` with `Restart=on-failure`:

```ini
[Unit]
Description=QuillPHP API Server
Description=QuillPHP Application
After=network.target

[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/my-api
Environment=QUILL_WORKERS=8
ExecStart=/usr/bin/php bin/quill serve --port=8080
WorkingDirectory=/var/www/html
ExecStart=/usr/bin/php bin/quill serve
Restart=on-failure
RestartSec=5
Environment=APP_ENV=prod
Environment=QUILL_WORKERS=4

[Install]
WantedBy=multi-user.target
```

```bash
systemctl enable --now quill-api
```

---

## 8. Security Hardening
## Signals

- Run the process as a non-root user.
- Ensure `libquill.so` is owned by `root` with `755` permissions.
- Configure OS-level file descriptor limits: `ulimit -n 65535`.
- Use a reverse proxy for TLS; Quill speaks plain HTTP internally.
QuillPHP handles the following signals:

- **SIGINT / SIGTERM**: Graceful shutdown. The parent process forwards the signal to all children and waits for them to exit before shutting down.
- **SIGCHLD**: Monitored by the parent to detect child process exits.
41 changes: 24 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,35 @@ QuillPHP is a **binary-native** API framework built for extreme low-latency envi

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.

### Performance at Scale
### Performance: Industry Benchmarks (TFB R22)

*Hardware: 48-core AMD EPYC 7R13, 512 connections. Source: [TechEmpower Round 22 JSON](https://www.techempower.com/benchmarks/#hw=ph&test=json&section=data-r22).*

| Framework | Throughput (req/s) | Avg Latency |
|:---|---:|---:|
| Actix-web 4 (Rust) | ~450,000 | ~0.22 ms |
| Axum 0.7 (Rust / Tokio) | ~330,000 | ~0.30 ms |
| Go Fiber v2 (fasthttp) | ~220,000 | ~0.45 ms |
| Go net/http (stdlib) | ~115,000 | ~0.87 ms |
| Node.js Fastify v4 | ~68,000 | ~1.47 ms |
| Node.js Express v4 | ~18,000 | ~5.56 ms |
| FastAPI + Uvicorn | ~11,000 | ~9.09 ms |

### Performance: Direct Local Measurement

*Hardware: Apple M-series (ARM64), 100 connections.*

| 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.
| **QuillPHP (Native)** | **133,627** | **1.16 ms** | `wrk` on macOS¹ |
| FrankenPHP (worker) | ~30,000 | ~3.33 ms | Estimated² |
| Laravel Octane (Swoole) | ~10,000 | ~10.0 ms | Bare route³ |

> ¹ **QuillPHP measurement** — `wrk -t4 -c100 -d10s`, `QUILL_WORKERS=4`, macOS 14.4+, PHP 8.3 (NTS), Rust core built with `--release`.
>
> ³ **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.
> ² **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.
>
> **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.
> ³ **Laravel Octane** — Bare `Route::get('/hello', fn() => [...])` with no sessions, DB, or auth middleware in a production-optimised build.

---

Expand Down
9 changes: 8 additions & 1 deletion src/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,14 @@ private function runWithQuill(): void
$port = (int)(getenv('QUILL_PORT') ?: 8080);
$this->router->compile();

$server = new Server($this->router);
$dtoBufferSize = (isset($this->config['ffi_dto_buffer_size']) && is_numeric($this->config['ffi_dto_buffer_size']))
? (int)$this->config['ffi_dto_buffer_size']
: 65536;
$errorBufferSize = (isset($this->config['ffi_error_buffer_size']) && is_numeric($this->config['ffi_error_buffer_size']))
? (int)$this->config['ffi_error_buffer_size']
: 4096;

$server = new Server($this->router, null, null, $dtoBufferSize, $errorBufferSize);
$server->start($port);
}

Expand Down
9 changes: 9 additions & 0 deletions src/Http/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ public function getStatus(): int
return $this->status;
}

/**
* Get the response headers.
* @return array<string, string>
*/
public function getHeaders(): array
{
return $this->headers;
}

/**
* Total bytes written to output by this response.
*/
Expand Down
13 changes: 11 additions & 2 deletions src/Routing/RouteMatch.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,12 @@ public function __construct(

public function isFound(): bool
{
return $this->status === 1; // Dispatcher::FOUND
return $this->status === RouterStatus::FOUND;
}

public function isMethodNotAllowed(): bool
{
return $this->status === 2; // Dispatcher::METHOD_NOT_ALLOWED
return $this->status === RouterStatus::METHOD_NOT_ALLOWED;
}

/**
Expand Down Expand Up @@ -90,15 +90,24 @@ public function execute(Request $request): mixed

if (is_array($handler) && isset($handler[0], $handler[1])) {
$class = is_scalar($handler[0]) ? (string)$handler[0] : '';
/** @var string $method */
$method = is_scalar($handler[1]) ? (string)$handler[1] : '';
$key = "$class::$method";

if ($this->container && $this->container->has($class)) {
$instance = $this->container->get($class);
} else {
if (!class_exists($class)) {
throw new \RuntimeException("Handler class '{$class}' not found.");
}
/** @var object $instance */
$instance = $this->instanceCache[$class] ??= new $class();
}

if (!is_object($instance) || !method_exists($instance, $method)) {
throw new \RuntimeException("Method '{$method}' not found on handler class '{$class}'.");
}

$args = $this->resolveArgs($key, $request);
/** @phpstan-ignore-next-line */
return $instance->$method(...$args);
Expand Down
28 changes: 14 additions & 14 deletions src/Routing/Router.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ public function getParamCache(): array { return $this->paramCache; }
/** @return array<string, object> */
public function getInstanceCache(): array { return $this->instanceCache; }
public function getContainer(): ?ContainerInterface { return $this->container; }
public function getHandle(): ?\FFI\CData { return $this->handle; }
public function getHandle(): mixed { return $this->handle; }

public function dispatch(string $method, string $path): RouteMatch
{
Expand All @@ -182,9 +182,9 @@ public function dispatch(string $method, string $path): RouteMatch

$res = $this->match($method, $path);

if ($res['status'] === 1) { // Found
if ($res['status'] === RouterStatus::FOUND) { // Found
/** @var array{int, callable|array<string>, array<string, string>} $info */
$info = [1, $this->routes[$res['handler_id']][2], $res['params']];
$info = [RouterStatus::FOUND, $this->routes[$res['handler_id']][2], $res['params']];
return new RouteMatch(
$info,
$this->paramCache,
Expand All @@ -193,15 +193,15 @@ public function dispatch(string $method, string $path): RouteMatch
);
}

if ($res['status'] === 2) { // Not Found
return new RouteMatch([0, [], []], $this->paramCache, $this->instanceCache, $this->container);
if ($res['status'] === RouterStatus::NOT_FOUND) { // Not Found
return new RouteMatch([RouterStatus::NOT_FOUND, [], []], $this->paramCache, $this->instanceCache, $this->container);
}

if ($res['status'] === 3) { // Method Not Allowed
return new RouteMatch([2, [], []], $this->paramCache, $this->instanceCache, $this->container);
if ($res['status'] === RouterStatus::METHOD_NOT_ALLOWED) { // Method Not Allowed
return new RouteMatch([RouterStatus::METHOD_NOT_ALLOWED, [], []], $this->paramCache, $this->instanceCache, $this->container);
}

return new RouteMatch([0, [], []], $this->paramCache, $this->instanceCache, $this->container);
return new RouteMatch([RouterStatus::NOT_FOUND, [], []], $this->paramCache, $this->instanceCache, $this->container);
}

/**
Expand All @@ -214,8 +214,9 @@ private function match(string $method, string $path): array
$numParams = $ffi->new('uint32_t[1]');
$paramsJson = $ffi->new('char[2048]');

$res = 0;
/** @phpstan-ignore-next-line */
$res = $ffi->quill_router_match(
$res = (int)$ffi->quill_router_match(
$this->handle,
$method, strlen($method),
$path, strlen($path),
Expand All @@ -225,21 +226,20 @@ private function match(string $method, string $path): array
);

if ($res === 1) { // Not Found
return ['status' => 2, 'handler_id' => 0, 'params' => []];
return ['status' => RouterStatus::NOT_FOUND, 'handler_id' => 0, 'params' => []];
}

if ($res === 2) { // Method Not Allowed
return ['status' => 3, 'handler_id' => 0, 'params' => []];
return ['status' => RouterStatus::METHOD_NOT_ALLOWED, 'handler_id' => 0, 'params' => []];
}

/** @var array<string, string> $params */
/** @var \FFI\CData $paramsJson */
$params = ($numParams instanceof \FFI\CData && (int)$numParams[0] > 0)
$params = ($numParams instanceof \FFI\CData && (int)$numParams[0] > 0 && $paramsJson instanceof \FFI\CData)
? json_decode(\FFI::string($paramsJson), true, 512, JSON_THROW_ON_ERROR)
: [];

return [
'status' => 1, // Found
'status' => RouterStatus::FOUND,
'handler_id' => $handlerId instanceof \FFI\CData ? (int)$handlerId[0] : 0,
'params' => $params,
];
Expand Down
Loading
Loading