PHP Binding¶
Spikard's PHP binding uses ext-php-rs with PSR-compliant APIs and structured error handling. Handlers receive typed Request objects; responses are plain PHP arrays or Response objects with automatic JSON serialization. The Rust core handles routing, middleware, and streaming.
Quickstart¶
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use Spikard\App;
use Spikard\Attributes\Get;
use Spikard\Config\ServerConfig;
use Spikard\Http\Response;
final class HelloController
{
#[Get('/')]
public function index(): Response
{
return Response::text('Hello, World!');
}
}
$config = new ServerConfig(port: 8000);
$app = (new App($config))->registerController(new HelloController());
$app->run();
Installation¶
Install via Composer:
Normal installs require PHP 8.2+ and Composer 2.x. The package auto-installs its native extension through a Composer plugin; in CI or other non-interactive environments, allow it first with composer config allow-plugins.spikard/spikard true.
Rust is only required if you are building the extension from source.
Configuration¶
use Spikard\Config\ServerConfig;
$config = new ServerConfig(
port: 8000,
host: '127.0.0.1',
workers: 4, // Optional: worker threads
);
$app = new App($config);
Routing¶
Register routes with controller attributes:
use Spikard\Attributes\Get;
use Spikard\Attributes\Post;
final class UsersController
{
#[Get('/users')]
public function list(): Response
{
return Response::json(['users' => []]);
}
#[Post('/users')]
public function create(Request $request): Response
{
$data = $request->body;
return Response::json(['id' => 1] + $data, 201);
}
#[Get('/users/{id:int}')]
public function show(Request $request): Response
{
$id = $request->pathParams['id'];
return Response::json(['id' => $id, 'name' => 'Alice']);
}
}
Request & Response¶
Handlers receive Spikard\Http\Request objects and return responses:
use Spikard\Attributes\Post;
use Spikard\Http\Request;
use Spikard\Http\Response;
final class DataController
{
#[Post('/data')]
public function store(Request $request): Response
{
// Access request data
$method = $request->method;
$path = $request->path;
$headers = $request->headers;
$query = $request->query;
$pathParams = $request->pathParams;
$body = $request->body; // Array from JSON payload
// Return responses
return Response::text('Plain text');
return Response::json(['key' => 'value']);
return Response::json(['error' => 'Not found'], 404);
}
}
Validation¶
Validate request bodies manually or use schema validation:
use Spikard\Attributes\Post;
use Spikard\Http\Request;
use Spikard\Http\Response;
final class ValidationController
{
#[Post('/users')]
public function create(Request $request): Response
{
$data = $request->body;
// Manual validation
if (!isset($data['name'], $data['email'])) {
return Response::json(
['error' => 'Missing required fields: name, email'],
400
);
}
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
return Response::json(
['error' => 'Invalid email format'],
400
);
}
return Response::json(['id' => 1, 'name' => $data['name']], 201);
}
}
Dependency Injection¶
Register values and factories in a DependencyContainer:
use Spikard\DI\DependencyContainer;
use Spikard\DI\Provide;
$container = new DependencyContainer(
values: [
'app_name' => 'My API',
'db_config' => ['host' => 'localhost', 'port' => 5432],
],
factories: [
'database' => new Provide(
factory: function (array $db_config): Database {
return new Database($db_config['host'], $db_config['port']);
},
dependsOn: ['db_config'],
singleton: true
),
]
);
$app = (new App($config))->withDependencies($container);
Lifecycle Hooks¶
Register hooks for cross-cutting behavior:
// Called on every request
$app = $app->onRequest(function (array $request): array {
error_log("{$request['method']} {$request['path']}");
return $request;
});
// Called before validation
$app = $app->preValidation(function (array $request): array {
return $request;
});
// Called before handler
$app = $app->preHandler(function (array $request): array {
return $request;
});
// Called after handler completes
$app = $app->onResponse(function (array $response): array {
return $response;
});
// Called on handler error
$app = $app->onError(function (array $error): array {
error_log("Error: " . json_encode($error));
return $error;
});
Error Handling¶
Handlers should return error responses with appropriate status codes:
use Spikard\Http\Request;
use Spikard\Http\Response;
use Spikard\Attributes\Get;
final class DataController
{
#[Get('/data/{id:int}')]
public function show(Request $request): Response
{
$id = (int) ($request->pathParams['id'] ?? 0);
if ($id < 1) {
return Response::json(
[
'error' => 'Invalid ID',
'code' => 'INVALID_INPUT',
'details' => ['id' => 'must be positive']
],
400
);
}
return Response::json(['id' => $id, 'data' => 'content']);
}
}
$app = $app->registerController(DataController::class);
Response Types¶
Handlers return Response objects with flexible body and header support:
use Spikard\Http\Response;
// JSON response (auto-serialized)
return Response::json(['id' => 1, 'name' => 'Alice']);
return Response::json(['error' => 'Not found'], 404);
// Plain text response
return Response::text('Plain text content');
// Custom response with headers and cookies
return new Response(
body: ['custom' => 'data'],
statusCode: 201,
headers: ['X-Custom-Header' => 'value'],
cookies: ['session' => 'abc123']
);
// Response builder methods
$response = Response::json(['items' => []])
->withCookies(['auth' => 'token123']);
Streaming Responses¶
For large files, real-time data, or Server-Sent Events:
use Spikard\Http\StreamingResponse;
#[Get('/events')]
public function stream(): StreamingResponse
{
$events = function(): Generator {
for ($i = 0; $i < 5; $i++) {
yield "data: " . json_encode(['count' => $i]) . "\n\n";
sleep(1);
}
};
return StreamingResponse::sse($events());
}
#[Get('/download')]
public function downloadFile(): StreamingResponse
{
return StreamingResponse::file(
'/path/to/large-file.zip',
chunkSize: 65536
);
}
#[Get('/records')]
public function streamJsonLines(): StreamingResponse
{
$records = function(): Generator {
foreach ($this->database->fetchLargeResult() as $row) {
yield $row;
}
};
return StreamingResponse::jsonLines($records());
}
Request Parameter Extraction¶
Extract typed parameters from requests with optional validation:
use Spikard\Http\Params\{Query, Path, Header, Body, Cookie};
use Spikard\Attributes\Post;
final class ItemController
{
#[Post('/items')]
public function create(
array $body = new Body(
schema: [
'type' => 'object',
'required' => ['name', 'price'],
'properties' => [
'name' => ['type' => 'string'],
'price' => ['type' => 'number', 'minimum' => 0],
],
]
)
): Response {
return Response::json(['id' => 1] + $body, 201);
}
#[Get('/items')]
public function list(
int $limit = new Query(default: 10),
int $offset = new Query(default: 0),
array $tags = new Query(defaultFactory: fn() => [])
): Response {
return Response::json(['items' => []]);
}
#[Get('/items/{id:int}')]
public function show(
int $id = new Path()
): Response {
return Response::json(['id' => $id]);
}
#[Get('/items/{id:int}')]
public function withHeader(
int $id = new Path(),
string $token = new Header(default: '')
): Response {
return Response::json(['authorized' => !empty($token)]);
}
}
Server Configuration¶
Complete configuration options via ServerConfig:
use Spikard\Config\{
ServerConfig,
CompressionConfig,
RateLimitConfig,
CorsConfig,
JwtConfig,
ApiKeyConfig,
StaticFilesConfig,
OpenApiConfig,
};
$config = ServerConfig::builder()
->withHost('0.0.0.0')
->withPort(8080)
->withWorkers(4)
->withMaxBodySize(52428800) // 50 MB
->withRequestTimeout(60)
->withCompression(new CompressionConfig(
enabled: true,
minBodySize: 1024,
level: 6,
))
->withRateLimit(new RateLimitConfig(
requestsPerSecond: 100,
burst: 50,
))
->withCors(new CorsConfig(
allowedOrigins: ['https://app.example.com'],
allowedMethods: ['GET', 'POST', 'PUT'],
allowedHeaders: ['Content-Type', 'Authorization'],
allowCredentials: true,
maxAge: 3600,
))
->withJwtAuth(new JwtConfig(
secret: $_ENV['JWT_SECRET'],
algorithms: ['HS256'],
issuer: 'api.example.com',
))
->withStaticFiles(new StaticFilesConfig(
directory: __DIR__ . '/public',
urlPath: '/static',
))
->withOpenApi(new OpenApiConfig(
enabled: true,
title: 'My API',
version: '1.0.0',
))
->build();
$app = (new App($config))->registerController(new ItemController());
$app->run();
File Uploads¶
Handle multipart form data with file uploads:
use Spikard\Attributes\Post;
use Spikard\Http\Request;
use Spikard\Http\Response;
final class FileController
{
#[Post('/upload')]
public function upload(Request $request): Response
{
$files = $request->files;
if (empty($files)) {
return Response::json(['error' => 'No files provided'], 400);
}
$uploaded = [];
foreach ($files as $fieldName => $fileArray) {
// $fileArray is ['name' => '...', 'tmp_name' => '...', 'size' => ..., ...]
$uploaded[] = [
'field' => $fieldName,
'name' => $fileArray['name'],
'size' => $fileArray['size'],
];
}
return Response::json(['uploaded' => $uploaded], 201);
}
}
WebSocket Support¶
Real-time bidirectional communication with WebSocket handlers:
use Spikard\Handlers\WebSocketHandlerInterface;
use Spikard\Attributes\Route;
final class ChatWebSocketHandler implements WebSocketHandlerInterface
{
private array $connections = [];
#[Route('/ws/chat', methods: ['WEBSOCKET'])]
public function onConnect(): void
{
error_log("Client connected");
}
public function onMessage(string $message): void
{
// Broadcast to all connections
error_log("Received: {$message}");
// Send back confirmation
}
public function onClose(int $code, ?string $reason = null): void
{
error_log("Client disconnected: {$code} - {$reason}");
}
}
$app->registerWebSocketHandler(new ChatWebSocketHandler());
Server-Sent Events (SSE)¶
Stream events to clients with automatic formatting:
use Spikard\Handlers\SseEventProducerInterface;
use Spikard\Http\StreamingResponse;
use Spikard\Attributes\Get;
use Generator;
final class NotificationController
{
#[Get('/events/notifications')]
public function stream(): StreamingResponse
{
$producer = new class implements SseEventProducerInterface {
public function __invoke(): Generator
{
for ($i = 0; $i < 10; $i++) {
yield "data: " . json_encode([
'timestamp' => time(),
'message' => "Event {$i}",
]) . "\n\n";
sleep(1);
}
}
};
return StreamingResponse::sse($producer());
}
}
gRPC Services¶
Define gRPC handlers for protocol buffer services:
use Spikard\Grpc;
use Spikard\Grpc\{Service, Request, Response, HandlerInterface};
final class UserServiceHandler implements HandlerInterface
{
public function handleRequest(Request $request): Response
{
if ($request->methodName === 'GetUser') {
// Decode protobuf payload
$userId = unpack('N', substr($request->payload, 0, 4))[1];
// Encode response
$responseData = pack('N', $userId) . 'Alice';
return new Response($responseData);
}
return Response::error('Unknown method');
}
}
$grpcService = Grpc::createService();
$grpcService->registerHandler('users.UserService', new UserServiceHandler());
// In controller
#[Post('/users.UserService/GetUser')]
public function grpcHandler(Request $request, Service $service): Response
{
$grpcRequest = Grpc::createRequest(
'users.UserService',
'GetUser',
$request->body
);
return $service->handleRequest($grpcRequest);
}
Background Tasks¶
Execute work asynchronously without blocking responses:
use Spikard\Background\BackgroundTask;
use Spikard\Attributes\Post;
use Spikard\Http\Response;
final class EmailController
{
#[Post('/send-email')]
public function sendEmail(): Response
{
// Return immediately
BackgroundTask::run(function($email, $subject) {
error_log("Sending email to: {$email}");
sleep(2); // Simulated work
error_log("Email sent");
}, ['user@example.com', 'Welcome']);
return Response::json(['queued' => true], 202);
}
}
Testing¶
Write integration tests with TestClient:
use PHPUnit\Framework\TestCase;
use Spikard\Testing\TestClient;
use Spikard\App;
use Spikard\Config\ServerConfig;
final class ApiTest extends TestCase
{
private TestClient $client;
protected function setUp(): void
{
$config = new ServerConfig(port: 8000);
$app = (new App($config))->registerController(new ItemController());
$this->client = TestClient::create($app);
}
protected function tearDown(): void
{
$this->client->close();
}
public function testGetItems(): void
{
$response = $this->client->get('/items?limit=5');
$this->assertEquals(200, $response->getStatusCode());
$data = $response->parseJson();
$this->assertArrayHasKey('items', $data);
}
public function testCreateItem(): void
{
$response = $this->client->post('/items', [
'name' => 'Widget',
'price' => 9.99,
]);
$this->assertEquals(201, $response->getStatusCode());
$data = $response->parseJson();
$this->assertEquals('Widget', $data['name']);
}
public function testItemNotFound(): void
{
$response = $this->client->get('/items/9999');
$this->assertEquals(404, $response->getStatusCode());
}
public function testWebSocketConnection(): void
{
$ws = $this->client->connectWebSocket('/ws/chat');
// Test WebSocket communication
}
public function testSseStream(): void
{
$stream = $this->client->connectSse('/events/notifications');
// Test SSE event stream
}
}
Deployment¶
- Local development:
php app.php - Production: Set environment variables
SPIKARD_PORTandSPIKARD_HOST - Requires PHP 8.2+ and compiled ext-php-rs extension
Troubleshooting¶
- Extension not loading: Ensure ext-php-rs is compiled and loaded. Run
composer installto trigger post-install build. - Type errors: Enable strict_types=1 and use PHPStan for static analysis:
composer run phpstan. - Port in use: Change port in ServerConfig or set
SPIKARD_PORTenv variable. - Request parsing fails: Check Content-Type headers and JSON payload validity.
- WebSocket/SSE unavailable: WebSocket and SSE testing requires native extension. Set
SPIKARD_TEST_CLIENT_FORCE_PHP=1to disable.
Standards Compliance¶
- PSR-4: Autoloading via Composer
spikard/spikardnamespace - PSR-12: Code style enforced via php-cs-fixer
- PSR-7: HTTP message interfaces via Request/Response classes
- Static Analysis: PHPStan level max (
composer run lint) - Testing: PHPUnit tests in
packages/php/tests/with 80%+ coverage
For more details, see the PHP examples.