Attribute-driven routing plus a small runtime.
BraidPHP combines module composition, route scanning, immutable HTTP message objects, and a lightweight TCP listener. Dependency injection and provider storage come from dvictorjhg/php-injector.
PHP ^8.4 / PSR-7 / TCP
BraidPHP is an attribute-driven PHP framework for small HTTP applications that want module composition, explicit routing, and a visible request lifecycle.
<?php
namespace dvictorjhg\braidphp\Example\Controllers;
use dvictorjhg\braidphp\Router\Attributes\Get;
use dvictorjhg\braidphp\Router\Attributes\Route;
use dvictorjhg\braidphp\Router\Http\Request;
use dvictorjhg\braidphp\Example\Providers\GreeterProvider;
#[Route(path: '/api')]
final class GreeterComponent
{
public function __construct(private GreeterProvider $greeterProvider)
{
}
#[Get('/hello/:name')]
public function greet(Request $request): string
{
$name = $request->getRouteParam('name') ?? '';
return $this->greeterProvider->getGreeting($name);
}
}
curl http://127.0.0.1:8000/api/hello/Ada
# Hello Ada!
This guide stays close to the repository: the source under src/, the Example application, the PHPUnit suite, Composer metadata, Docker files, and the CI workflow.
BraidPHP combines module composition, route scanning, immutable HTTP message objects, and a lightweight TCP listener. Dependency injection and provider storage come from dvictorjhg/php-injector.
App::listen() accepts a socket connection, parses one HTTP request, resolves one route action, writes one response, and closes the client. There is no worker pool or async event loop in this repository.
| Area | Verified in | What to expect |
|---|---|---|
src/Core |
App, #[Module], ModuleScanner |
Application bootstrap, module ordering, imports, providers, controllers, and bootstrap services. |
src/Router |
Router, route attributes, HTTP messages |
Route matching, path parameters, custom matchers, request parsing, response serialization, URI and stream helpers. |
Example/ |
AppModule, modules, controllers, providers, index.php |
A real application graph that shows imports, constructor injection, attribute routing, query parameters, multi-method routes, and the server entry point. |
.github/workflows/ci.yml |
GitHub Actions | Composer validation, platform checks, PHPStan, PHP_CodeSniffer, PHPUnit coverage, Codecov upload, the CI-side 80% composer coverage gate, and separate 50% Codecov targets. |
This walkthrough creates only the files the framework needs: composer.json, a PSR-4 src/ tree, one module, one controller, one provider, and a front controller that starts App::listen().
Start with a composer.json that declares PHP ^8.4, requires the package, and maps the App namespace to src/.
{
"name": "acme/hello-braid",
"require": {
"php": "^8.4",
"dvictorjhg/braidphp": "^1.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
composer installThe provider is a plain PHP class. The controller uses a class prefix and a GET route that reads the captured :name parameter from the immutable request copy.
src/Greeter.php
<?php
declare(strict_types=1);
namespace App;
final class Greeter
{
public function greeting(string $name): string
{
return "Hello $name!";
}
}
src/GreetingController.php
<?php
declare(strict_types=1);
namespace App;
use dvictorjhg\braidphp\Router\Attributes\Get;
use dvictorjhg\braidphp\Router\Attributes\Route;
use dvictorjhg\braidphp\Router\Http\Request;
#[Route(path: '/api')]
final class GreetingController
{
public function __construct(private Greeter $greeter)
{
}
#[Get('/hello/:name', pathMatch: 'full')]
public function hello(Request $request): string
{
return $this->greeter->greeting($request->getRouteParam('name') ?? '');
}
}
Import HttpModule so the injector can resolve Router::class, then register the provider and controller.
src/AppModule.php
<?php
declare(strict_types=1);
namespace App;
use dvictorjhg\braidphp\Core\Attributes\Module;
use dvictorjhg\braidphp\Router\HttpModule;
#[Module(
imports: [HttpModule::class],
providers: [Greeter::class],
controllers: [GreetingController::class],
)]
final class AppModule
{
}
The entry point follows the same boot sequence as Example/index.php: autoload, bootstrap the root module, then listen on SERVER_ADDRESS and SERVER_PORT with defaults.
index.php
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use App\AppModule;
use dvictorjhg\braidphp\Core\App;
$app = new App();
$app->bootstrapModule(new AppModule());
$address = (string) getenv('SERVER_ADDRESS') ?: '0.0.0.0';
$port = (string) getenv('SERVER_PORT') ?: '8000';
$app->listen(address: $address, port: $port);
php index.php
Terminal 2curl http://127.0.0.1:8000/api/hello/Ada
Run the server in one terminal and the curl request in a second terminal. The server prints "Server listening on 0.0.0.0:8000..." and the request returns "Hello Ada!". Every extra request is handled by the same process in sequence.
A module is just the #[Module] attribute data. App::bootstrapModule() processes imports first, then providers, controllers, and bootstrap entries.
// Example/AppModule.php
<?php
declare(strict_types=1);
namespace dvictorjhg\braidphp\Example;
use dvictorjhg\braidphp\Core\Attributes\Module;
use dvictorjhg\braidphp\Example\Modules\GreeterModule;
use dvictorjhg\braidphp\Example\Modules\HealthModule;
use dvictorjhg\braidphp\Router\HttpModule;
#[Module(
imports: [
HttpModule::class,
GreeterModule::class,
HealthModule::class,
],
)]
class AppModule
{
}
// Example/Modules/GreeterModule.php
<?php
declare(strict_types=1);
namespace dvictorjhg\braidphp\Example\Modules;
use dvictorjhg\braidphp\Core\Attributes\Module;
use dvictorjhg\braidphp\Example\Controllers\GreeterComponent;
use dvictorjhg\braidphp\Example\Modules\GreeterProviderModule;
#[Module(
imports: [GreeterProviderModule::class],
controllers: [GreeterComponent::class],
)]
class GreeterModule
{
}
// Example/Modules/GreeterProviderModule.php
<?php
declare(strict_types=1);
namespace dvictorjhg\braidphp\Example\Modules;
use dvictorjhg\braidphp\Core\Attributes\Module;
use dvictorjhg\braidphp\Example\Providers\GreeterProvider;
#[Module(
providers: [GreeterProvider::class],
)]
class GreeterProviderModule
{
}
// Example/Modules/HealthModule.php
<?php
declare(strict_types=1);
namespace dvictorjhg\braidphp\Example\Modules;
use dvictorjhg\braidphp\Core\Attributes\Module;
use dvictorjhg\braidphp\Example\Controllers\HealthComponent;
use dvictorjhg\braidphp\Example\Providers\HealthProvider;
#[Module(
providers: [HealthProvider::class],
controllers: [HealthComponent::class],
)]
class HealthModule
{
}
Module classes or objects to bootstrap before the current module.
An array or PHPInjector Container passed to a new Injector.
Class strings or objects scanned for public route methods.
Keyed startup entries resolved after routes and providers are ready.
imports are bootstrapped first, in container iteration order.providers create a new injector whose parent is the previously active injector, so earlier imported providers stay visible unless a later identifier shadows them.controllers are scanned only after the injector is ready.bootstrap runs last; keyed class names are resolved through the injector, while existing objects are kept as-is.HttpModule registers Router::class.PHPInjector\DI\Injector::inject().App accepts an optional array or Container in its constructor. Use it when infrastructure should exist before bootstrapModule() runs.
<?php
declare(strict_types=1);
use dvictorjhg\braidphp\Core\App;
require_once __DIR__ . '/vendor/autoload.php';
$app = new App([
'env' => 'development',
]);
Bootstrap entries are keyed by class name. Arrays become constructor arguments for that class, while prebuilt objects are left untouched.
<?php
declare(strict_types=1);
use dvictorjhg\braidphp\Core\App;
use dvictorjhg\braidphp\Core\Attributes\Module;
use dvictorjhg\braidphp\Router\HttpModule;
require_once __DIR__ . '/vendor/autoload.php';
final class BootstrapService
{
}
final class ExistingService
{
}
#[Module(
imports: [HttpModule::class],
bootstrap: [
BootstrapService::class => [],
ExistingService::class => new ExistingService(),
],
)]
final class StartupModule
{
}
$app = new App();
$app->bootstrapModule(new StartupModule());
| Provider form | Meaning in BraidPHP | Example |
|---|---|---|
Greeter::class |
Lazy class resolution through the injector. | [Greeter::class] |
Interface::class => Impl::class |
Alias one identifier to a concrete class string provider. | [Formatter::class => HtmlFormatter::class] |
Service::class => new Service(...) |
Inject an existing object exactly as supplied. | [Clock::class => new SystemClock()] |
'env' => 'prod' |
Store a named scalar or context value for explicit lookup or #[Inject] usage in PHPInjector. | ['env' => 'production'] |
Container |
Use PHPInjector\Container\Container instead of a plain array when you need container semantics directly. | new Container([...]) |
RouteScanner reads class and method attributes, Router walks the resulting tree depth-first, and App::handleRequest() attaches the matched parameters to a request copy.
<?php
namespace dvictorjhg\braidphp\Example\Controllers;
use dvictorjhg\braidphp\Router\Attributes\Get;
use dvictorjhg\braidphp\Router\Attributes\Route;
use dvictorjhg\braidphp\Router\Http\HttpMethod;
use dvictorjhg\braidphp\Router\Http\Request;
use dvictorjhg\braidphp\Router\Http\Response;
#[Route(path: '/api')]
final class GreeterComponent
{
#[Route(
httpMethod: HttpMethod::GET->value | HttpMethod::POST->value,
path: '/hi/:name'
)]
public function hi(Request $request): string
{
$name = $request->getRouteParam('name') ?? '';
return "Hi $name!";
}
#[Get('/hello/:name')]
public function greet(Request $request): string
{
$name = $request->getRouteParam('name') ?? '';
return 'Hello ' . $name . '!';
}
#[Get('/hello')]
public function greetQueryParam(Request $request): Response
{
$name = $request->getQueryParams()['name'] ?? null;
return \is_string($name) && $name !== ''
? new Response(body: 'Hello ' . $name . '!')
: new Response(400);
}
}
GET /api/hello/Ada/api/hello/:name:name = Ada/api/hello?name=AdaUse getQueryParams() for query strings; route params and query params are separate.
prefix / fullprefix is the default; full rejects leftover segments at that route level.
All nine shortcut attributes extend the generic Route attribute.
#[Get]
HEAD#[Head]
POST#[Post]
PUT#[Put]
DELETE#[Delete]
CONNECT#[Connect]
OPTIONS#[Options]
TRACE#[Trace]
PATCH#[Patch]
#[Route(path: ...)] values are prefixes by default; set pathMatch: 'full' when that route level must reject leftover segments.:name captures a decoded path segment and makes it available through Request::getRouteParam().Request::getQueryParams().Router; the first matching branch wins.#[Route] attribute with a combined integer mask such as HttpMethod::GET->value | HttpMethod::POST->value, or by stacking distinct shortcut attributes such as #[Get] and #[Post].App::handleRequest() returns 404 Not Found.Router::processRoutes() throws InvalidArgumentException.UrlMatcherResult or null, otherwise RouterException is thrown.The #[Get] shortcut and generic #[Route] attribute resolve to the same GET route. Choose one declaration for a route.
#[Get('/hello/:name', pathMatch: 'full')]
public function hello(Request $request): string
{
return 'Hello ' . ($request->getRouteParam('name') ?? '') . '!';
}
#[Route(
httpMethod: HttpMethod::GET,
path: '/hello/:name',
pathMatch: 'full',
)]
public function hello(Request $request): string
{
return 'Hello ' . ($request->getRouteParam('name') ?? '') . '!';
}
Stacking both on one method creates duplicate route entries for the same path and makes precedence harder to reason about.
Programmatic routes can use a custom matcher instead of a path string, but not both at once.
<?php
declare(strict_types=1);
use dvictorjhg\braidphp\Router\Classes\Route;
use dvictorjhg\braidphp\Router\Classes\UrlMatcherResult;
use dvictorjhg\braidphp\Router\Http\HttpMethod;
use dvictorjhg\braidphp\Router\Http\Response;
use dvictorjhg\braidphp\Router\Router;
require_once __DIR__ . '/vendor/autoload.php';
final class HealthComponent
{
public function status(): Response
{
return new Response(body: 'ok');
}
}
$router = new Router();
$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: [HealthComponent::class, 'status'],
));
$matched = $router->processRoutes(new \dvictorjhg\braidphp\Router\Http\Request(
method: HttpMethod::GET,
uri: '/health',
));
echo $matched !== null ? 'matched' : 'not matched';
matched
Request and Response extend a shared Message base class. Uri and Stream implement the relevant PSR interfaces directly, and with* methods return changed copies.
Request implements RequestInterface, parses query parameters on construction, injects a Host header from the URI when needed, and stores route parameters only on withRouteParams() copies.
<?php
declare(strict_types=1);
use dvictorjhg\braidphp\Router\Http\HttpMethod;
use dvictorjhg\braidphp\Router\Http\Request;
require_once __DIR__ . '/vendor/autoload.php';
$request = new Request(
method: HttpMethod::GET,
uri: 'http://example.test:8080/hello?name=Ada',
body: '',
);
$routed = $request
->withRouteParams(['scope' => 'demo'])
->withHeader('X-Trace', 'one')
->withAddedHeader('X-Trace', 'two');
echo ($request->getRouteParam('scope') ?? 'none') . "\n";
echo $routed->getHeaderLine('x-trace') . "\n";
echo $routed->getHeaderLine('host') . "\n";
none
one,two
example.test:8080Response implements ResponseInterface, supplies known reason phrases automatically, and writes Content-Type plus Content-Length during string serialization when those headers were not supplied.
<?php
declare(strict_types=1);
use dvictorjhg\braidphp\Router\Http\Response;
require_once __DIR__ . '/vendor/autoload.php';
$response = new Response(
statusCode: 201,
body: 'welcome',
);
echo $response->getStatusCode() . PHP_EOL;
echo $response->getReasonPhrase() . PHP_EOL;
echo (string) $response;
201
Created
HTTP/1.1 201 Created
Content-Type: text/plain
Content-Length: 7
welcome| Component | Actual behavior | Notes |
|---|---|---|
Request |
Implements RequestInterface, not ServerRequestInterface. |
Query params come from the URI; route params are BraidPHP-specific helpers. |
Response |
Wraps the body as a stream and serializes itself to a wire-format HTTP response string. | Explicit Content-Length and Content-Type headers are preserved. |
Uri |
Implements UriInterface, normalizes scheme/host/query data, and keeps derived path parts in sync. |
Path parameters are matched against getPathParts(). |
Stream |
Implements StreamInterface over native PHP stream resources. |
Detached streams reject tell/read/write/seek/eof/getContents operations. |
StreamFactory |
Helper for creating PSR streams from scalars, native PHP stream resources, stringable objects, or existing streams. | This repository does not ship PSR-17 request or response factory interfaces. |
Response, BraidPHP sends it unchanged.null, scalars, native PHP stream resources, PSR streams, and stringable objects are wrapped into new Response(200, [], ...).App::responseFromResult().withHeader(), withAddedHeader(), withoutHeader(), withBody(), and URI with* methods are immutable.Stream::of() accepts scalar values, native stream resources, stringable objects, existing PSR streams, or null.Run this complete example from the project root after composer install. It exercises module bootstrap, named configuration, constructor injection, decoded path parameters, query validation, and immutable requests.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
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\HttpMethod;
use dvictorjhg\braidphp\Router\Http\Request;
use dvictorjhg\braidphp\Router\Http\Response;
use dvictorjhg\braidphp\Router\HttpModule;
use PHPInjector\DI\Attributes\Inject;
final class Profiles
{
public function __construct(#[Inject('env')] private string $environment)
{
}
public function response(string $name): Response
{
return new Response(
headers: ['Content-Type' => 'application/json'],
body: json_encode(['name' => $name, 'env' => $this->environment], JSON_THROW_ON_ERROR),
);
}
}
#[Route(path: '/api')]
final class ProfilesController
{
public function __construct(private Profiles $profiles)
{
}
#[Get('/users/:name', pathMatch: 'full')]
public function user(Request $request): Response
{
return $this->profiles->response($request->getRouteParam('name') ?? '');
}
#[Get('/users', pathMatch: 'full')]
public function query(Request $request): Response
{
$name = $request->getQueryParams()['name'] ?? null;
if (!is_string($name) || $name === '') {
return new Response(400, ['Content-Type' => 'application/json'], '{"error":"name is required"}');
}
return $this->profiles->response($name);
}
#[Get('/fail', pathMatch: 'full')]
public function fail(): never
{
throw new RuntimeException('demo failure');
}
}
#[Module(
imports: [HttpModule::class],
providers: [Profiles::class, 'env' => 'test'],
controllers: [ProfilesController::class],
)]
final class ProfilesModule
{
}
$app = new App();
$app->bootstrapModule(new ProfilesModule());
$request = new Request(method: HttpMethod::GET, uri: '/api/users/Ada%20Lovelace');
$response = $app->handleRequest($request);
echo $response->getStatusCode() . ' ' . (string) $response->getBody() . PHP_EOL;
echo $response->getHeaderLine('Content-Type') . PHP_EOL;
var_dump($request->getRouteParams());
foreach (['/api/users?name=', '/api/users/Ada/extra', '/missing'] as $uri) {
$response = $app->handleRequest(new Request(method: HttpMethod::GET, uri: $uri));
echo $response->getStatusCode() . ' ' . (string) $response->getBody() . PHP_EOL;
}
try {
$app->handleRequest(new Request(method: HttpMethod::GET, uri: '/api/fail'));
} catch (RuntimeException $exception) {
echo $exception->getMessage();
}
200 {"name":"Ada Lovelace","env":"test"}
application/json
array(0) {
}
400 {"error":"name is required"}
404 Not Found
404 Not Found
demo failure
The documentation checker executes marked examples in isolated PHP processes and compares exact output. It needs PHP DOM and proc_open. The first-app files and live example routes can also be checked through handleRequest() without binding a port.
php tools/check-docs.php
composer test
The injector is globally active, not request scoped. Avoid request data in cached services and avoid bootstrapping unrelated applications in the same process.
The request lifecycle is short enough to read end-to-end in App.php, RequestParser.php, Router.php, and the Example controllers.
RequestParser reads the request line and headers from the stream, then reads the body according to Content-Length when it is present. An empty client stream yields no request.
Router compares the request method against the HttpMethod enum, walks the registered routes in order, and collects decoded :name path parameters into RouteMatch::params.
App::handleRequest() clones the matched request with route params and invokes the route action through PHPInjector. The request copy is passed as an explicit argument keyed by its class name.
A Response is returned unchanged. Null, scalars, native PHP stream resources, existing PSR streams, and stringable objects become Response(200, [], body). Arrays raise AppException, while objects that are neither stringable nor streams raise InvalidArgumentException during body conversion. The socket loop converts uncaught throwables into a 500 response body starting with “Server Error:”.
<?php
declare(strict_types=1);
use dvictorjhg\braidphp\Core\App;
use dvictorjhg\braidphp\Core\Attributes\Module;
use dvictorjhg\braidphp\Router\HttpModule;
require_once __DIR__ . '/vendor/autoload.php';
#[Module(imports: [HttpModule::class])]
final class RuntimeModule
{
}
$app = new App();
$app->bootstrapModule(new RuntimeModule());
$app->listen(
address: '0.0.0.0',
port: '8000',
);
Use a process manager, reverse proxy, or container platform when the application needs more than one worker.
App::handleRequest() returns a plain Not Found response.
The listen loop catches it and writes “Server Error: …” in the response body.
The repository ships both direct PHP entry points and container-oriented workflows for development and production images.
SERVER_ADDRESS: defaults to 0.0.0.0 in Example/index.php.SERVER_PORT: defaults to 8000 and is also published by the container helpers.ENVIRONMENT, APP_PATH, DOCKER_IMAGE_TAG, and XDEBUG_MODE are defined in .env for the checked-in container workflows.php Example/index.php or your own front controller for direct local runs.8.5.10, while the package requirement remains PHP ^8.4.docker-compose.yml builds docker/php/php.Dockerfile, passes APP_PATH and ENVIRONMENT, exposes SERVER_PORT, and mounts src/, Example/, tests/, coverage/, and debug/.
docker compose up --build php
bin/podman-run.sh and bin/podman-run.ps1 both read .env, build the selected target from docker/php/php.Dockerfile, create debug/ and coverage/, publish SERVER_PORT, and mount source directories in development mode.
./bin/podman-run.sh --environment development --detach
podman exec braidphp-development composer test
./bin/podman-run.sh --action down
composer.json excludes Example/ when Composer creates an archive. Repository and Packagist archive contents are controlled by their hosting and distribution settings. The Example tree remains in this repository and in the container images because it is the runnable demo.
This is a minimal HTTP listener, not a hardened edge server. TLS, worker supervision, timeouts, request size limits, and rate limiting need an external boundary. Chunked request decoding and persistent connections are not provided. Return sanitized errors in application code: listener-generated 500 responses expose exception messages. Do not assume HEAD automatically suppresses a response body or an unmatched method returns 405.
The contributor workflow is intentionally plain: Composer validation, platform checks, static analysis, coding standards, PHPUnit, coverage, and a small release checklist.
php tools/check-docs.php
composer validate --strict
composer check-platform-reqs
composer analyse
composer check-style
composer test
composer test:coverage
composer coverage:check
The CI workflow runs on PHP 8.4 and 8.5, uploads build/test-results/clover.xml to Codecov, and fails the local coverage gate if statements drop below 80%.
For a release, update CHANGELOG.md, run the commands above, create and push an annotated tag such as 1.0.2, publish a GitHub Release, and refresh the package on Packagist.