A native PHP extension implementing high-performance PSR-7 HTTP Request and Response classes with zero-copy operations and direct superglobal access.
- Native C implementation - all HTTP operations run in native code
- Zero-copy string streams - reference strings directly without data duplication
- Direct HashTable access - bypass PHP arrays for superglobal data
- Lazy evaluation - parse data only when accessed
- Immutable objects - all
with*()methods return new instances - Memory efficient - proper reference counting and cleanup
- PSR-7 compliant - implements
ServerRequestInterface,ResponseInterface,StreamInterface,UriInterface, andUploadedFileInterface - Optimized for php-fpm - designed for FastCGI environments
- No dependencies - pure C extension with no external libraries
HTTP request/response handling is invoked on nearly every request, often hundreds of times. Moving HTTP operations to native code provides:
- Direct superglobal access - bypass PHP's array layer for $_SERVER, $_GET, $_POST, $_COOKIE, $_FILES
- Zero-copy string operations - reference string data directly without duplication
- Native hash tables - efficient storage and lookup for headers and parameters
- Reduced overhead - minimal PHP engine interaction during data access
- Memory efficiency - proper reference counting and cleanup
- Lazy evaluation - parse JSON/form data only when requested
- Immutable operations - efficient object cloning with shared data structures
- Full PSR-7 Compliance: Implements
ServerRequestInterface,ResponseInterface,StreamInterface,UriInterface, andUploadedFileInterface - Zero Dependencies: Pure C extension with no external libraries
- Hyper-Performance: Direct HashTable access, zero-copy operations, lazy evaluation
- Immutable Objects: All
with*()methods return new instances - Memory Efficient: Proper reference counting and cleanup
The extension integrates seamlessly with the Streamforge FastCGI proxy for high-performance file upload handling. When Streamforge is deployed between nginx and php-fpm, it provides several benefits depending on your nginx configuration.
Important: nginx's fastcgi_request_buffering setting affects what problem streamforge solves:
WITH fastcgi_request_buffering ON (nginx default):
┌────────┐ ┌───────────────┐ ┌─────────┐ ┌───────────┐
│ Client │────▶│ nginx buffers │────▶│ php-fpm │────▶│ $_FILES │
└────────┘ └───────────────┘ └─────────┘ └───────────┘
slow fast │
(to disk) Worker engaged only
during fast transfer
WITH fastcgi_request_buffering OFF:
┌────────┐ ┌───────┐ ┌─────────┐
│ Client │────▶│ nginx │────▶│ php-fpm │ ← Worker blocked for entire upload!
└────────┘ └───────┘ └─────────┘
slow streams
directly
With default nginx settings, workers are already protected from slow clients. The upload is buffered by nginx first.
| nginx setting | Streamforge benefit |
|---|---|
fastcgi_request_buffering on (default) | Avoids double temp file write, reduces PHP memory, skips multipart parsing in PHP |
fastcgi_request_buffering off | Full benefit: Workers not blocked during slow uploads |
This is where streamforge shines. Configure nginx to stream directly:
location /upload {fastcgi_request_buffering off; # Stream to backend
fastcgi_pass streamforge:9001;}Now streamforge handles the slow client I/O:
┌────────┐ ┌─────────────┐ ┌─────────┐
│ Client │────▶│ streamforge │────▶│ php-fpm │
└────────┘ └─────────────┘ └─────────┘
slow writes to disk Worker engaged
as data arrives only ~5ms
| Scenario | Without Streamforge | With Streamforge |
|---|---|---|
| 500MB upload, slow client | Worker blocked ~30s | Worker engaged ~5ms |
| 20 concurrent uploads, 10 workers | Site unresponsive | No impact |
Streamforge still provides value, just different:
- No double write: nginx buffer → streamforge disk (not nginx buffer → PHP temp)
- Less PHP memory: No request body buffering in PHP
- No multipart parsing: PHP doesn't parse multipart boundaries
- Consistent API: Same
HTTP_X_UPLOAD_*interface regardless of nginx config
The extension automatically detects Streamforge and reads uploads from the appropriate source. Your application code remains unchanged:
// Works identically with or without Streamforge$request = Request::capture();
$files = $request->getUploadedFiles();
foreach ($filesas$name => $file) {
$file->getClientFilename(); // "document.pdf"$file->getSize(); // 52428800$file->moveTo('/storage/docs/document.pdf');
}Check if Streamforge is handling the current request:
useSignalforge\NativeHttp\Request;
// Static methodif (Request::isStreamforgeEnabled()) {
// Streamforge is proxying this request
}
// Or check $_SERVER directlyif (isset($_SERVER['HTTP_X_STREAMFORGE'])) {
// Streamforge marker present
}
// Check for processed uploadsif (isset($_SERVER['HTTP_X_UPLOAD_FILE_COUNT'])) {
$count = (int) $_SERVER['HTTP_X_UPLOAD_FILE_COUNT'];
// Streamforge handled $count file uploads
}When Streamforge handles multipart uploads, it:
- Parses the multipart body and writes files to disk
- Adds metadata headers to the FastCGI request:
HTTP_X_STREAMFORGE=1- Proxy markerHTTP_X_UPLOAD_FILE_COUNT=N- Number of uploaded filesHTTP_X_UPLOAD_0_NAME- Form field nameHTTP_X_UPLOAD_0_FILENAME- Original client filenameHTTP_X_UPLOAD_0_PATH- Temp file path on diskHTTP_X_UPLOAD_0_SIZE- File size in bytesHTTP_X_UPLOAD_0_TYPE- MIME type
- Sends only form fields (not file content) to PHP-FPM
The extension reads these headers and creates UploadedFile objects that work identically to standard PHP uploads.
Temp files are automatically cleaned up:
- On
moveTo(): File is moved, no cleanup needed - On request end: Unmoved temp files are deleted by the extension's RSHUTDOWN handler
This prevents disk space leaks even if application code doesn't handle all uploaded files.
See the Streamforge documentation for deployment instructions. Basic setup:
# Start Streamforge between nginx and php-fpm
streamforge -l 0.0.0.0:9001 -u /var/run/php-fpm.sock -d /tmp/uploads
# Configure nginx to send requests to Streamforge# fastcgi_pass 127.0.0.1:9001;- PHP 8.3, 8.4, or 8.5
- Linux or macOS (tested on x86_64 and ARM64)
- php-fpm recommended (works in CLI for testing)
No need to install PHP dev headers on your host:
cd http
# Build Docker image with extension
make docker-build
# Run tests
make docker-test
# Test all PHP versions (8.3, 8.4, 8.5)
make ci-test-all
# Run example
make docker-examplecd http
phpize
./configure --enable-signalforge_http
make
make test
sudo make installThen add extension=signalforge_http.so to your php.ini.
<?phpuseSignalforge\NativeHttp\Request;
// Capture the current request$request = Request::capture();
// HTTP Method & URI$method = $request->getMethod(); // "POST"$target = $request->getRequestTarget(); // "/users/123?include=profile"$path = $request->getUri(); // "/users/123?include=profile"// Headers (case-insensitive)$contentType = $request->getHeader('Content-Type'); // ['application/json']$contentTypeLine = $request->getHeaderLine('Content-Type'); // "application/json"$hasAuth = $request->hasHeader('Authorization'); // true/false$allHeaders = $request->getHeaders(); // ['content-type' => ['application/json']]// Parameters$queryParams = $request->getQueryParams(); // $_GET as array$parsedBody = $request->getParsedBody(); // JSON/form data (lazy parsed)// Body access$bodyStream = $request->getBody(); // StreamInterface$rawBody = (string) $request->getBody(); // Raw body string// Server & environment$serverParams = $request->getServerParams(); // $_SERVER$userAgent = $serverParams['HTTP_USER_AGENT'];
// Cookies$cookies = $request->getCookieParams(); // $_COOKIE as array$sessionId = $cookies['session_id'];
// Uploaded files$files = $request->getUploadedFiles(); // Normalized file structureif (isset($files['avatar'])) {
$filename = $files['avatar']->getClientFilename();
$files['avatar']->moveTo('/uploads/' . $filename);
}
// Attributes (middleware data)$request = $request->withAttribute('user_id', 123);
$userId = $request->getAttribute('user_id'); // 123$userId = $request->getAttribute('missing', 'default'); // 'default'// Immutable modifications$newRequest = $request
->withMethod('PUT')
->withHeader('X-API-Key', 'secret')
->withQueryParams(['limit' => 10])
->withParsedBody(['name' => 'John']);
// Original request unchangedassert($request->getMethod() === 'POST');
assert($newRequest->getMethod() === 'PUT');<?phpuseSignalforge\NativeHttp\Response;
useSignalforge\NativeHttp\Stream;
// Factory methods$response = Response::create(200, ['Content-Type' => 'application/json']);
$response = Response::json(['users' => ['id' => 1, 'name' => 'John']], 200);
$response = Response::text('Hello World', 200);
$response = Response::html('<h1>Welcome</h1>', 200);
$response = Response::redirect('/login', 302);
// Status management$statusCode = $response->getStatusCode(); // 200$reasonPhrase = $response->getReasonPhrase(); // "OK"$response = $response->withStatus(404, 'Not Found');
// Header management (case-insensitive)$response = $response->withHeader('Content-Type', 'application/json');
$response = $response->withAddedHeader('Cache-Control', 'no-cache');
$response = $response->withAddedHeader('Cache-Control', 'private');
$hasHeader = $response->hasHeader('Content-Type'); // true$headerValue = $response->getHeader('Content-Type'); // ['application/json']$headerLine = $response->getHeaderLine('Content-Type'); // "application/json"$allHeaders = $response->getHeaders();
// Body management$stream = Stream::fromString('{"message": "Hello"}');
$response = $response->withBody($stream);
$bodyStream = $response->getBody();
// Output$response->send(); // Send headers + body$response->sendHeaders(); // Send only headers$response->sendBody(); // Send only body// Serialization$message = (string) $response; // Full HTTP message<?phpuseSignalforge\NativeHttp\Stream;
// Factory methods$stream = Stream::fromString('Hello World'); // TRUE zero-copy string reference$stream = Stream::fromResource(fopen('file.txt', 'r')); // From PHP resource$stream = Stream::fromFile('/path/to/file', 'r'); // From file path// Reading operations$data = $stream->read(5); // Read 5 bytes: "Hello"$remaining = $stream->getContents(); // Get rest: " World"$stream->rewind(); // Reset to beginning$all = (string) $stream; // Get entire contents// Writing operations (use file or resource streams for writing)$writableStream = Stream::fromFile('/tmp/output.txt', 'w+');
$bytesWritten = $writableStream->write('Hello'); // Write data$writableStream->write(' World'); // Append more// Seeking operations$stream->seek(6); // Seek to position 6$position = $stream->tell(); // Get current position: 6$stream->rewind(); // Reset to beginning// Stream capabilities$isReadable = $stream->isReadable(); // Check if can read$isWritable = $stream->isWritable(); // Check if can write$isSeekable = $stream->isSeekable(); // Check if supports seeking$atEnd = $stream->eof(); // Check if at end// Metadata and size$size = $stream->getSize(); // Size in bytes (or null)$metadata = $stream->getMetadata(); // All metadata$uri = $stream->getMetadata('uri'); // Specific metadata key// Resource management$underlying = $stream->detach(); // Detach PHP resource$stream->close(); // Close stream and free resources<?phpuseSignalforge\NativeHttp\Uri;
// Parse a URI string$uri = Uri::fromString('https://user:pass@example.com:8080/path?query=value#fragment');
// Access components (PSR-7 UriInterface)$scheme = $uri->getScheme(); // "https"$userInfo = $uri->getUserInfo(); // "user:pass"$host = $uri->getHost(); // "example.com"$port = $uri->getPort(); // 8080 (null if standard port for scheme)$path = $uri->getPath(); // "/path"$query = $uri->getQuery(); // "query=value"$fragment = $uri->getFragment(); // "fragment"$authority = $uri->getAuthority(); // "user:pass@example.com:8080"// Serialize to string$uriString = (string) $uri; // "https://user:pass@example.com:8080/path?query=value#fragment"// Immutable modifications$newUri = $uri
->withScheme('http')
->withHost('api.example.com')
->withPort(null) // Remove explicit port
->withPath('/v2/users')
->withQuery('limit=10')
->withFragment('');
// Original URI unchangedassert($uri->getHost() === 'example.com');
assert($newUri->getHost() === 'api.example.com');
// Standard ports are normalized to null$httpsUri = Uri::fromString('https://example.com:443/path');
$port = $httpsUri->getPort(); // null (443 is standard for https)<?phpuseSignalforge\NativeHttp\Request;
// Get uploaded files from request$request = Request::capture();
$files = $request->getUploadedFiles();
// Single file uploadif (isset($files['avatar'])) {
$file = $files['avatar'];
// File properties$size = $file->getSize(); // Size in bytes$error = $file->getError(); // UPLOAD_ERR_* constant$clientName = $file->getClientFilename(); // Original filename$mimeType = $file->getClientMediaType(); // MIME type// Move file to permanent location$targetPath = '/uploads/avatars/' . uniqid() . '_' . $clientName;
$file->moveTo($targetPath);
// Note: moveTo() can only be called once per UploadedFile
}
// Multiple file uploadif (isset($files['photos'])) {
foreach ($files['photos'] as$photo) {
if ($photo->getError() === UPLOAD_ERR_OK) {
$filename = $photo->getClientFilename();
$photo->moveTo('/uploads/photos/' . $filename);
}
}
}
// Stream access (alternative to moveTo)$stream = $file->getStream();
$content = $stream->getContents();<?phpuseSignalforge\NativeHttp\{Request, Response, Stream};
// Middleware-style request processingfunctionauthenticate(Request$request): Request
{
$token = $request->getHeaderLine('Authorization');
$userId = validateToken($token);
return$request->withAttribute('user_id', $userId);
}
functionvalidateJson(Request$request): Request
{
$contentType = $request->getHeaderLine('Content-Type');
if (!str_contains($contentType, 'application/json')) {
thrownewInvalidArgumentException('JSON content type required');
}
return$request;
}
// Request processing pipeline$request = Request::capture();
$request = authenticate($request);
$request = validateJson($request);
// JSON API response$data = ['users' => getUsers($request->getAttribute('user_id'))];
$response = Response::json($data, 200);
// CORS headers$response = $response
->withHeader('Access-Control-Allow-Origin', '*')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// Conditional responseif ($request->hasHeader('If-None-Match')) {
$etag = $request->getHeaderLine('If-None-Match');
if ($etag === generateEtag($data)) {
$response = $response->withStatus(304); // Not Modified
}
}
$response->send();Request::capture(): ServerRequestInterface // Capture current request from superglobalsgetProtocolVersion(): string // Get HTTP protocol version (always "1.1" in FastCGI)withProtocolVersion(string $version): static // Return new instance with protocol versiongetHeaders(): array // Get all headers as lowercase key => array valueshasHeader(string $name): bool // Check if header exists (case-insensitive)getHeader(string $name): array // Get header values arraygetHeaderLine(string $name): string // Get header values as comma-separated stringwithHeader(string $name, string|array $value): static // Replace header (case-insensitive)withAddedHeader(string $name, string|array $value): static // Add to existing headerwithoutHeader(string $name): static // Remove headergetBody(): StreamInterface // Get message body streamwithBody(StreamInterface $body): static // Replace body streamgetRequestTarget(): string // Get request target (path + query)withRequestTarget(string $target): static // Set request targetgetMethod(): string // Get HTTP methodwithMethod(string $method): static // Set HTTP methodgetUri(): string // Get URI as stringwithUri(string|UriInterface $uri, bool $preserveHost = false): static // Set URIgetServerParams(): array // Get $_SERVER parametersgetCookieParams(): array // Get $_COOKIE parameterswithCookieParams(array $cookies): static // Replace cookiesgetQueryParams(): array // Get $_GET parameterswithQueryParams(array $query): static // Replace query parametersgetUploadedFiles(): array // Get uploaded files structurewithUploadedFiles(array $files): static // Replace uploaded filesgetParsedBody(): array|object|null // Get parsed body (JSON/form data)withParsedBody(array|object|null $data): static // Set parsed bodygetAttributes(): array // Get request attributesgetAttribute(string $name, mixed $default = null) // Get single attributewithAttribute(string $name, mixed $value): static // Add/replace attributewithoutAttribute(string $name): static // Remove attributeResponse::create(int $status = 200, array $headers = [], mixed $body = null): static
Response::json(mixed $data, int $status = 200): static
Response::text(string $text, int $status = 200): static
Response::html(string $html, int $status = 200): static
Response::redirect(string $url, int $status = 302): staticgetProtocolVersion(): string // Get HTTP protocol versionwithProtocolVersion(string $version): static // Set protocol versiongetHeaders(): array // Get all headershasHeader(string $name): bool // Check header existsgetHeader(string $name): array // Get header valuesgetHeaderLine(string $name): string // Get comma-separated headerwithHeader(string $name, string|array $value): static // Replace headerwithAddedHeader(string $name, string|array $value): static // Add header valuewithoutHeader(string $name): static // Remove headergetBody(): StreamInterface // Get body streamwithBody(StreamInterface $body): static // Replace body streamgetStatusCode(): int // Get HTTP status codewithStatus(int $code, string $reason = ''): static // Set status code and reasongetReasonPhrase(): string // Get reason phrasesend(): void // Send response (headers + body)sendHeaders(): void // Send only headerssendBody(): void // Send only body__toString(): string // Serialize to HTTP messageStream::fromString(string $string): static // Create from string (zero-copy)
Stream::fromResource(resource $resource): static // Create from PHP stream resource
Stream::fromFile(string $path, string $mode = 'r'): static // Create from fileread(int $length): string // Read data from stream
getContents(): string // Get remaining contentswrite(string $string): int // Write data to streamseek(int $offset, int $whence = SEEK_SET): void // Seek to positiontell(): int // Get current positionrewind(): void // Seek to beginningeof(): bool // Check if at end of streamisReadable(): bool // Check if stream is readableisWritable(): bool // Check if stream is writableisSeekable(): bool // Check if stream supports seekinggetSize(): ?int // Get stream size (if known)getMetadata(?string $key = null): mixed // Get stream metadataclose(): void // Close stream and free resourcesdetach(): resource|null // Detach underlying resource__toString(): string // Get entire stream contentsgetStream(): StreamInterface // Get file contents as streammoveTo(string $targetPath): void // Move file to new locationgetSize(): ?int // Get file size in bytesgetError(): int // Get upload error code (UPLOAD_ERR_*)getClientFilename(): ?string // Get original client filenamegetClientMediaType(): ?string // Get client-provided MIME typeUri::fromString(string $uri): UriInterface // Parse URI string (RFC 3986 compliant)getScheme(): string // Get URI scheme (http, https, etc.)getAuthority(): string // Get authority (userinfo@host:port)getUserInfo(): string // Get user info (user:pass)getHost(): string // Get host (lowercase)getPort(): ?int // Get port (null if standard for scheme)getPath(): string // Get path componentgetQuery(): string // Get query string (without ?)getFragment(): string // Get fragment (without #)withScheme(string $scheme): UriInterface // Return new instance with schemewithUserInfo(string $user, ?string $pass = null): UriInterface // Set user infowithHost(string $host): UriInterface // Set hostwithPort(?int $port): UriInterface // Set port (null to remove)withPath(string $path): UriInterface // Set pathwithQuery(string $query): UriInterface // Set query stringwithFragment(string $fragment): UriInterface // Set fragment__toString(): string // Serialize to URI stringThe extension provides significant performance improvements over userland PSR-7 implementations through native C code, direct superglobal access, and zero-copy operations. Benchmarks comparing against other PSR-7 implementations can be found in the http-php repository.
- Direct superglobal access - bypasses PHP's array layer for $_SERVER, $_GET, $_POST, $_COOKIE, $_FILES
- Zero-copy string streams - reference strings directly without data duplication
- Native hash tables - efficient storage and lookup for headers and parameters
- Lazy evaluation - parse JSON/form data only when accessed
- Immutable operations - efficient object cloning with shared data structures
- Memory efficient - proper reference counting and cleanup
- Direct superglobal access - References $_SERVER, $_GET, $_POST, $_COOKIE, $_FILES directly
- Lazy header parsing - Headers parsed only when
getHeaders()is called - JSON caching - Parsed JSON bodies cached to avoid re-parsing
- Immutable cloning -
with*()methods create efficient clones with shared data
- String streams: TRUE zero-copy references to existing strings (no data duplication)
- Resource streams: Efficient
php_stream_copy_to_mem()for large data - Lazy loading: Stream contents read only when accessed
- Position tracking: Efficient position management for seekable streams
- Reference counting: Proper Zend reference counting throughout
- Object pooling: Reuses memory structures where possible
- Automatic cleanup: Destructors handle resource cleanup
- Leak prevention: All allocations properly tracked and freed
http/
├── config.m4 # Build configuration
├── signalforge_http.c # PHP class implementations
├── php_signalforge_http.h # Main header
├── src/
│ ├── request.c/h # Request class implementation
│ ├── response.c/h # Response class implementation
│ ├── stream.c/h # Stream class implementation
│ ├── uri.c/h # Uri class implementation
│ ├── uploadedfile.c/h # UploadedFile class implementation
│ ├── psr7_interfaces.c/h # PSR-7 interface definitions
├── Signalforge/Http/ # IDE stubs
├── examples/ # Usage examples
├── tests/ # phpt test files (97 tests)
└── Dockerfile # Docker build environment
make testOr run specific tests:
docker run --rm signalforge-http php /opt/run-tests.php tests/001_basic.phpt# Docker-based Valgrind (recommended)
make valgrind-docker
# Local Valgrind (requires valgrind installed)
make valgrind-testThe extension supports ZTS (Zend Thread Safety) builds. Each request gets isolated instances, and all operations are thread-safe.
InvalidArgumentException- Invalid parameters or malformed dataRuntimeException- Stream operations, file access errors- Standard PHP exceptions for JSON parsing errors
- signalforge/http-php - PHP Composer package with PSR-7/PSR-17 wrappers
- PSR-7 - HTTP Message Interface specification
- PSR-17 - HTTP Factories specification
MIT License