PHP 8.4+ / PSR-7 / TCP

Route the request. Keep the rest native.

An attribute-driven PHP framework for composing modules, matching routes, and serving small HTTP applications without hiding the request.

09HTTP methods
PSRmessage contracts
1Pprocess by design
A useful first file
<?php

declare(strict_types=1);

use dvictorjhg\braidphp\Core\App;
use dvictorjhg\braidphp\Core\Attributes\Module;
use dvictorjhg\braidphp\Router\Attributes\Get;
use dvictorjhg\braidphp\Router\Attributes\Route;
use dvictorjhg\braidphp\Router\Http\Request;
use dvictorjhg\braidphp\Router\HttpModule;
use dvictorjhg\braidphp\Router\Router;

#[Route(path: '/api')]
final class GreetingController
{
    #[Get('/hello/:name')]
    public function hello(Request $request): string
    {
        return 'Hello ' . ($request->getRouteParam('name') ?? '') . '!';
    }
}

#[Module(
    imports: [HttpModule::class],
    controllers: [GreetingController::class]
)]
final class AppModule {}

$app = new App();
$app->bootstrapModule(new AppModule());
$app->listen(port: '8000');
Request
curl http://127.0.0.1:8000/api/hello/Ada

Hello Ada!
01 / Start here

Run the example in three steps.

Install the repository dependencies, start its front controller, then call a real route from a second terminal.

01 / Install

From the repository root, fetch the dependencies used by the example.

composer install
02 / Run

Start the listener on port 8000 and keep this terminal open.

php public/index.php
03 / Try

In a second terminal, call the hello route and check its response.

curl http://127.0.0.1:8000/api/hello/Ada

# Hello Ada!
02 / Modules

Compose the application in four moves.

A module is the boundary where imported infrastructure, injectable providers, route controllers, and startup work become one graph.

AppModule.php PHP
<?php

use dvictorjhg\braidphp\Core\Attributes\Module;
use dvictorjhg\braidphp\Router\HttpModule;

#[Module(
    imports: [HttpModule::class],
    providers: [Greeter::class],
    controllers: [GreetingController::class],
    bootstrap: [CacheWarmup::class => ['prefix' => 'app']]
)]
final class AppModule
{
}
01
imports

Bootstrap module classes or objects first.

02
providers

Register classes, values, aliases, or factories with PHPInjector.

03
controllers

Scan classes and objects for route attributes.

04
bootstrap

Resolve keyed startup classes after the graph is ready.

01ImportHttpModule
02Injectproviders
03Scancontrollers
04Startbootstrap
The router is a provider

Import HttpModule or register Router::class yourself before controllers are processed. The module scanner does not invent an application graph.

03 / Routing

Match paths where they are declared.

Class routes establish a prefix. Method attributes finish the path. Captures travel to the action on a request copy.

GreetingController.php ROUTES
#[Route(path: '/api')]
final class GreetingController
{
    public function __construct(private Greeter $greeter)
    {
    }

    #[Get('/hello/:name', pathMatch: 'full')]
    public function greet(Request $request): string
    {
        $name = $request->getRouteParam('name') ?? '';
        return $this->greeter->greeting($name);
    }

    #[Post('/hello')]
    public function create(Request $request): Response
    {
        return new Response(201, body: (string) $request->getBody());
    }
}
A request through the treeGET /api/hello/Ada
class prefix/api
method path/hello
capture:name = Ada
RouteMatch -> request copy -> action
Query/search?term=php

Read from getQueryParams().

Path matchprefix / full

Use full when a node must consume the remaining path.

Method attributes

Each shortcut fixes the HttpMethod value; Route accepts a combined integer mask for multi-method nodes.

GET#[Get] HEAD#[Head] POST#[Post] PUT#[Put] DELETE#[Delete] CONNECT#[Connect] OPTIONS#[Options] TRACE#[Trace] PATCH#[Patch]
Programmatic matcher

Return consumed segments, or return null.

Custom matchers receive path segments and the route. They return UrlMatcherResult with captured string parameters.

use dvictorjhg\braidphp\Router\Classes\Route;
use dvictorjhg\braidphp\Router\Classes\UrlMatcherResult;
use dvictorjhg\braidphp\Router\Http\HttpMethod;

$router->setRoutes(new Route(
    httpMethod: HttpMethod::GET,
    matcher: static function (array $parts, Route $route): ?UrlMatcherResult {
        return ($parts[0] ?? null) === 'health'
            ? new UrlMatcherResult(['health'])
            : null;
    },
    action: [HealthController::class, 'show'],
));
04 / HTTP messages

Change a message by making a message.

Request, response, URI, and stream objects follow PSR contracts. Header and URI changes are immutable and explicit.

REQUEST

Headers and route data

Query parameters are parsed on construction; route parameters arrive through withRouteParams().

$request = new Request(
    method: HttpMethod::GET,
    uri: '/health?verbose=1',
);

$traced = $request
    ->withHeader('X-Trace', 'one')
    ->withAddedHeader('X-Trace', 'two')
    ->withRouteParams(['scope' => 'live']);

echo $traced->getQueryParams()['verbose'];
Expected output1
RESPONSE

Bodies become streams

Pass scalar content or a PSR stream. A reason phrase is inferred when the status code is known.

$response = new Response(
    statusCode: 201,
    headers: ['X-Request' => 'one'],
    body: 'created',
);

echo $response->getReasonPhrase();
echo (string) $response;
Expected outputCreated Content-Length: 7
MemberBehaviorReturns
getHeader()Case-insensitive lookuplist<string>
withHeader()Replace values on a copyMessageInterface
withAddedHeader()Append values on a copyMessageInterface
getBody() / Stream::of()Read or create stream contentStreamInterface
withUri()Replace URI and refresh query dataRequestInterface
(string) $responseSerialize status, headers, and bodyHTTP/1.1 ...
Serialization detailContent-Type defaults to text/plain and Content-Length is calculated only when the response is converted to a string and those headers are absent.
05 / Runtime

A straight line from socket to response.

The runtime uses PHP streams to accept a request, resolve its action, and write one HTTP response. There is no hidden worker pool.

01Acceptstream_socket_accept
02ParseRequest::fromResource
03MatchRouter::processRoutes
04ResolvePHPInjector
05WriteResponse::__toString
Start the listenerBLOCKING
$app->listen(
    address: '0.0.0.0',
    port: '8000',
);

Use a process manager, container platform, or reverse proxy when the application needs more than one worker.

404
No matching route

handleRequest() returns Not Found.

500
Unhandled throwable

The socket loop writes Server Error with the exception message.

06 / API

The public surface, in one scan.

Start with App and the attributes. Reach for the lower-level router and HTTP classes when you need precise control.

MemberRoleContract
AppBootstraps modules and handles requestsbootstrapModule() handleRequest() listen()
#[Module]Declares application compositionimports providers controllers bootstrap
RouterStores and processes route treessetRoutes() processRoutes()
RouteScannerTurns attributes into route objectsscan() -> Route or RouteArray
RouteMatchCarries the selected route and capturesroute params
Request / ResponsePSR HTTP request and response messagesheaders, URI, body, status
Uri / StreamImmutable URI and PSR stream primitiveswith*() Stream::of()
UrlMatcherResultResult returned by a custom matcherconsumed params
07 / Development

Read the source. Run the checks.

The feedback loop is deliberately plain: Composer validation, platform checks, PHPStan, PHP_CodeSniffer, and PHPUnit.

CommandsCI
composer install
composer validate --strict
composer check-platform-reqs
composer analyse
composer check-style
composer test

CI runs this suite on PHP 8.4 and 8.5. The production image is PHP 8.5.9.