PSR-7 HTTP Messages¶
Overview¶
PSR-7 describes common interfaces for representing HTTP messages. XOOPS 2026 uses PSR-7 throughout its request/response lifecycle, enabling standardized handling and middleware compatibility.
Core Interfaces¶
MessageInterface¶
The base interface for both requests and responses:
namespace Psr\Http\Message;
interface MessageInterface
{
public function getProtocolVersion(): string;
public function withProtocolVersion(string $version): MessageInterface;
public function getHeaders(): array;
public function hasHeader(string $name): bool;
public function getHeader(string $name): array;
public function getHeaderLine(string $name): string;
public function withHeader(string $name, $value): MessageInterface;
public function withAddedHeader(string $name, $value): MessageInterface;
public function withoutHeader(string $name): MessageInterface;
public function getBody(): StreamInterface;
public function withBody(StreamInterface $body): MessageInterface;
}
RequestInterface¶
namespace Psr\Http\Message;
interface RequestInterface extends MessageInterface
{
public function getRequestTarget(): string;
public function withRequestTarget(string $requestTarget): RequestInterface;
public function getMethod(): string;
public function withMethod(string $method): RequestInterface;
public function getUri(): UriInterface;
public function withUri(UriInterface $uri, bool $preserveHost = false): RequestInterface;
}
ServerRequestInterface¶
Extended interface for server-side requests:
namespace Psr\Http\Message;
interface ServerRequestInterface extends RequestInterface
{
public function getServerParams(): array;
public function getCookieParams(): array;
public function withCookieParams(array $cookies): ServerRequestInterface;
public function getQueryParams(): array;
public function withQueryParams(array $query): ServerRequestInterface;
public function getUploadedFiles(): array;
public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface;
public function getParsedBody(): null|array|object;
public function withParsedBody($data): ServerRequestInterface;
public function getAttributes(): array;
public function getAttribute(string $name, $default = null): mixed;
public function withAttribute(string $name, $value): ServerRequestInterface;
public function withoutAttribute(string $name): ServerRequestInterface;
}
ResponseInterface¶
namespace Psr\Http\Message;
interface ResponseInterface extends MessageInterface
{
public function getStatusCode(): int;
public function withStatus(int $code, string $reasonPhrase = ''): ResponseInterface;
public function getReasonPhrase(): string;
}
XOOPS Implementation¶
Creating Requests from Globals¶
namespace Xoops\Core\Http;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7Server\ServerRequestCreator;
use Psr\Http\Message\ServerRequestInterface;
class RequestFactory
{
public static function fromGlobals(): ServerRequestInterface
{
$psr17Factory = new Psr17Factory();
$creator = new ServerRequestCreator(
$psr17Factory, // ServerRequestFactory
$psr17Factory, // UriFactory
$psr17Factory, // UploadedFileFactory
$psr17Factory // StreamFactory
);
return $creator->fromGlobals();
}
}
// Usage in index.php
$request = RequestFactory::fromGlobals();
$response = $kernel->handle($request);
Response Helper¶
namespace Xoops\Core\Http;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
class ApiResponse
{
private Psr17Factory $factory;
public function __construct()
{
$this->factory = new Psr17Factory();
}
/**
* Create HTML response
*/
public function html(string $content, int $status = 200): ResponseInterface
{
$response = $this->factory->createResponse($status);
$body = $this->factory->createStream($content);
return $response
->withHeader('Content-Type', 'text/html; charset=utf-8')
->withBody($body);
}
/**
* Create JSON response
*/
public function json(mixed $data, int $status = 200): ResponseInterface
{
$response = $this->factory->createResponse($status);
$body = $this->factory->createStream(
json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)
);
return $response
->withHeader('Content-Type', 'application/json')
->withBody($body);
}
/**
* Create redirect response
*/
public function redirect(string $url, int $status = 302): ResponseInterface
{
return $this->factory->createResponse($status)
->withHeader('Location', $url);
}
/**
* Create file download response
*/
public function download(
string $filePath,
string $filename = null,
string $contentType = 'application/octet-stream'
): ResponseInterface {
$filename = $filename ?? basename($filePath);
$body = $this->factory->createStreamFromFile($filePath);
return $this->factory->createResponse(200)
->withHeader('Content-Type', $contentType)
->withHeader(
'Content-Disposition',
sprintf('attachment; filename="%s"', $filename)
)
->withBody($body);
}
/**
* Create error response
*/
public function error(string $message, int $status = 500): ResponseInterface
{
return $this->json([
'error' => true,
'message' => $message,
'status' => $status,
], $status);
}
/**
* Create empty response
*/
public function noContent(): ResponseInterface
{
return $this->factory->createResponse(204);
}
}
Controller Usage¶
namespace Xoops\Module\Publisher\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Xoops\Core\Http\ApiResponse;
use Xoops\Core\View\ViewRendererInterface;
use Xoops\Module\Publisher\Service\ArticleService;
class ArticleController
{
public function __construct(
private readonly ArticleService $articleService,
private readonly ViewRendererInterface $view,
private readonly ApiResponse $response
) {}
/**
* List articles - returns HTML
*/
public function list(ServerRequestInterface $request): ResponseInterface
{
$page = (int) ($request->getQueryParams()['page'] ?? 1);
$articles = $this->articleService->getPaginated($page);
$html = $this->view->render('@modules/publisher/list', [
'articles' => $articles,
'page' => $page,
]);
return $this->response->html($html);
}
/**
* API endpoint - returns JSON
*/
public function apiList(ServerRequestInterface $request): ResponseInterface
{
$page = (int) ($request->getQueryParams()['page'] ?? 1);
$articles = $this->articleService->getPaginated($page);
return $this->response->json([
'data' => array_map(fn($a) => $a->toArray(), $articles),
'meta' => [
'page' => $page,
'per_page' => 20,
],
]);
}
/**
* Create article - handles POST
*/
public function create(ServerRequestInterface $request): ResponseInterface
{
$body = $request->getParsedBody();
// Validate
if (empty($body['title'])) {
return $this->response->error('Title is required', 400);
}
$article = $this->articleService->create($body);
return $this->response->json([
'data' => $article->toArray(),
'message' => 'Article created successfully',
], 201);
}
/**
* View single article
*/
public function view(ServerRequestInterface $request): ResponseInterface
{
// Route parameters are stored as request attributes
$id = (int) $request->getAttribute('id');
$article = $this->articleService->findById($id);
if ($article === null) {
return $this->response->error('Article not found', 404);
}
$html = $this->view->render('@modules/publisher/view', [
'article' => $article,
]);
return $this->response->html($html);
}
}
Working with Request Data¶
Query Parameters¶
// GET /articles?page=2&sort=date&order=desc
public function list(ServerRequestInterface $request): ResponseInterface
{
$params = $request->getQueryParams();
$page = (int) ($params['page'] ?? 1);
$sort = $params['sort'] ?? 'date';
$order = $params['order'] ?? 'desc';
// Use parameters...
}
POST Body¶
// POST with form data or JSON
public function store(ServerRequestInterface $request): ResponseInterface
{
// getParsedBody() returns array for form data or JSON
$body = $request->getParsedBody();
$title = $body['title'] ?? '';
$content = $body['content'] ?? '';
// For raw body access (e.g., custom format)
$rawBody = (string) $request->getBody();
}
File Uploads¶
use Psr\Http\Message\UploadedFileInterface;
public function upload(ServerRequestInterface $request): ResponseInterface
{
/** @var UploadedFileInterface[] $files */
$files = $request->getUploadedFiles();
if (!isset($files['image'])) {
return $this->response->error('No file uploaded', 400);
}
$file = $files['image'];
// Check for upload errors
if ($file->getError() !== UPLOAD_ERR_OK) {
return $this->response->error('Upload failed', 400);
}
// Validate file type
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (!in_array($file->getClientMediaType(), $allowedTypes)) {
return $this->response->error('Invalid file type', 400);
}
// Move uploaded file
$filename = sprintf('%s_%s', time(), $file->getClientFilename());
$targetPath = XOOPS_UPLOAD_PATH . '/' . $filename;
$file->moveTo($targetPath);
return $this->response->json([
'filename' => $filename,
'size' => $file->getSize(),
], 201);
}
Request Attributes¶
Attributes are used to pass data through middleware:
// In middleware
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
// Add user to request attributes
$user = $this->auth->getUser();
$request = $request->withAttribute('user', $user);
$request = $request->withAttribute('isAdmin', $user?->isAdmin() ?? false);
return $handler->handle($request);
}
// In controller
public function dashboard(ServerRequestInterface $request): ResponseInterface
{
$user = $request->getAttribute('user');
$isAdmin = $request->getAttribute('isAdmin', false);
// Use attributes...
}
Immutability¶
PSR-7 objects are immutable. Methods that "modify" the object return a new instance:
// Creating a modified request
$request = $originalRequest
->withHeader('X-Custom-Header', 'value')
->withAttribute('processed', true)
->withQueryParams(['page' => 2]);
// Original request is unchanged
assert($originalRequest->getHeader('X-Custom-Header') === []);
// Creating a modified response
$response = $originalResponse
->withStatus(201)
->withHeader('Location', '/articles/42');
Safe IO Integration¶
XOOPS provides a Safe IO layer on top of PSR-7:
namespace Xoops\Core\SafeIo;
use Psr\Http\Message\ServerRequestInterface;
class Request
{
private static ?ServerRequestInterface $request = null;
public static function setRequest(ServerRequestInterface $request): void
{
self::$request = $request;
}
public static function getInt(string $key, int $default = 0): int
{
$value = self::get($key);
return filter_var($value, FILTER_VALIDATE_INT) !== false
? (int) $value
: $default;
}
public static function getString(string $key, string $default = ''): string
{
$value = self::get($key);
if ($value === null) {
return $default;
}
// Remove null bytes and trim
return trim(str_replace("\0", '', (string) $value));
}
public static function getBool(string $key, bool $default = false): bool
{
$value = self::get($key);
if ($value === null) {
return $default;
}
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
public static function getArray(string $key, array $default = []): array
{
$params = self::$request?->getParsedBody() ?? [];
$value = $params[$key] ?? self::$request?->getQueryParams()[$key] ?? null;
return is_array($value) ? $value : $default;
}
private static function get(string $key): mixed
{
$body = self::$request?->getParsedBody() ?? [];
$query = self::$request?->getQueryParams() ?? [];
return $body[$key] ?? $query[$key] ?? null;
}
}
// Usage
$page = Request::getInt('page', 1);
$title = Request::getString('title', '');
$active = Request::getBool('active', false);
$tags = Request::getArray('tags', []);
Response Emitter¶
Sending the response to the client:
namespace Xoops\Core\Http;
use Psr\Http\Message\ResponseInterface;
class ResponseEmitter
{
public function emit(ResponseInterface $response): void
{
// Emit status line
$statusLine = sprintf(
'HTTP/%s %s %s',
$response->getProtocolVersion(),
$response->getStatusCode(),
$response->getReasonPhrase()
);
header($statusLine, true, $response->getStatusCode());
// Emit headers
foreach ($response->getHeaders() as $name => $values) {
$first = true;
foreach ($values as $value) {
header(sprintf('%s: %s', $name, $value), $first);
$first = false;
}
}
// Emit body
$body = $response->getBody();
if ($body->isSeekable()) {
$body->rewind();
}
while (!$body->eof()) {
echo $body->read(8192);
}
}
}
// In index.php
$emitter = new ResponseEmitter();
$emitter->emit($response);
Testing with PSR-7¶
use Nyholm\Psr7\Factory\Psr17Factory;
use PHPUnit\Framework\TestCase;
class ArticleControllerTest extends TestCase
{
private Psr17Factory $factory;
protected function setUp(): void
{
$this->factory = new Psr17Factory();
}
public function testListReturnsArticles(): void
{
// Create mock request
$request = $this->factory->createServerRequest('GET', '/articles')
->withQueryParams(['page' => 1]);
// Create controller with mocked dependencies
$controller = new ArticleController(
$this->createMock(ArticleService::class),
$this->createMock(ViewRendererInterface::class),
new ApiResponse()
);
$response = $controller->list($request);
$this->assertEquals(200, $response->getStatusCode());
$this->assertStringContainsString(
'text/html',
$response->getHeaderLine('Content-Type')
);
}
}