PHP ^8.4 / PSR-7 / TCP

BraidPHP

BraidPHP is an attribute-driven PHP framework for small HTTP applications that want module composition, explicit routing, and a visible request lifecycle.

09HTTP method attributes
PSRHTTP message contracts
1Pblocking process model
Example/Controllers/GreeterComponent.php
<?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);
    }
}
Try it
curl http://127.0.0.1:8000/api/hello/Ada
# Hello Ada!
01 / Overview

Know what the framework does before you write code.

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.

What BraidPHP is

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.

Runtime model

One request at a time, by design.

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.
Prerequisites
  • PHP ^8.4
  • Composer
  • A second terminal for curl while the server keeps running
Explicit limitations
  • No middleware pipeline
  • No multi-process runtime or event loop
  • No request factory interfaces beyond the StreamFactory helper
  • No templating, ORM, or session abstraction in this repository
02 / First application

Build the smallest working app from an empty directory.

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().

01

Create the project and install BraidPHP.

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/"
        }
    }
}
Runcomposer install
02

Add one provider and one controller.

The 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') ?? '');
    }
}
03

Declare the application module.

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
{
}
04

Create the front controller and make a request.

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);
Terminal 1php index.php Terminal 2curl http://127.0.0.1:8000/api/hello/Ada
What should happen

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.

03 / Modules

Use modules as the composition boundary.

A module is just the #[Module] attribute data. App::bootstrapModule() processes imports first, then providers, controllers, and bootstrap entries.

Example/AppModule.php and Example/Modules/* PHP
// 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
{
}
01
imports

Module classes or objects to bootstrap before the current module.

02
providers

An array or PHPInjector Container passed to a new Injector.

03
controllers

Class strings or objects scanned for public route methods.

04
bootstrap

Keyed startup entries resolved after routes and providers are ready.

Ordering
  • 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.
Dependency injection boundary
  • HttpModule registers Router::class.
  • Controllers are invoked through PHPInjector\DI\Injector::inject().
  • Constructor dependencies can come from the current module or any imported module/provider chain.
  • Only controller classes/objects become routes; providers by themselves do not expose HTTP endpoints.
App constructor providers

Seed providers before the first module.

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 example

Resolve startup services after routes exist.

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([...])
04 / Routing

Declare routes where the controller lives.

RouteScanner reads class and method attributes, Router walks the resulting tree depth-first, and App::handleRequest() attaches the matched parameters to a request copy.

Example/Controllers/GreeterComponent.php ROUTES
<?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);
    }
}
A request through the treeGET /api/hello/Ada
class prefix/api
method path/hello/:name
capture:name = Ada
RouteMatch -> request copy -> action
Query/api/hello?name=Ada

Use getQueryParams() for query strings; route params and query params are separate.

Path matchprefix / full

prefix is the default; full rejects leftover segments at that route level.

Supported HTTP method attributes

All nine shortcut attributes extend the generic Route attribute.

GET#[Get] HEAD#[Head] POST#[Post] PUT#[Put] DELETE#[Delete] CONNECT#[Connect] OPTIONS#[Options] TRACE#[Trace] PATCH#[Patch]
Matching behavior
  • Class-level #[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().
  • Query strings are parsed from the URI into Request::getQueryParams().
  • Routes are checked in the order they were added to Router; the first matching branch wins.
  • A method can accept multiple HTTP verbs through the generic #[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].
Failure cases
  • No route match: App::handleRequest() returns 404 Not Found.
  • Unsupported request method string: Router::processRoutes() throws InvalidArgumentException.
  • Invalid custom matcher result: the matcher must return UrlMatcherResult or null, otherwise RouterException is thrown.
  • Non-public methods with route attributes are ignored and emit a warning during scanning.
  • A class-level route without any method routes contributes no route entries.
Equivalent declarations

One GET route, two valid forms.

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') ?? '') . '!';
}
Do not stack both attributes

Stacking both on one method creates duplicate route entries for the same path and makes precedence harder to reason about.

Programmatic matcher

Return UrlMatcherResult or return null.

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';
Expected output
matched
05 / HTTP messages

Treat HTTP state as immutable values.

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

Request

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";
Expected outputnone one,two example.test:8080
RESPONSE

Response

Response 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;
Expected output201 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.
Action result conversion
  • If an action returns Response, BraidPHP sends it unchanged.
  • null, scalars, native PHP stream resources, PSR streams, and stringable objects are wrapped into new Response(200, [], ...).
  • Arrays are rejected explicitly by App::responseFromResult().
  • Objects that are neither stringable nor streams fail when the body is converted into a stream.
Serialization and streams
  • Header names are case-insensitive and preserve the original output casing used when they were set.
  • 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.
  • For seekable streams, serialization preserves content plus readable/writable/seekable flags. A non-seekable stream can serialize with empty content because rewinding fails.
Recipes / Executable examples

A JSON API without opening a socket.

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();
}
Expected output
200 {"name":"Ada Lovelace","env":"test"}
application/json
array(0) {
}
400 {"error":"name is required"}
404 Not Found
404 Not Found
demo failure

Action results are not automatically JSON

  • A framework Response is returned unchanged.
  • Null, scalars, native streams, PSR streams, and stringable objects become a 200 Response. Set your content type explicitly.
  • Arrays raise AppException; ordinary non-stringable objects fail stream conversion. Return json_encode() in a Response for JSON.
  • handleRequest() propagates action exceptions. Only listen() catches them and sends a 500 body containing the exception message.

Repeatable checks

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.

06 / Runtime lifecycle

Follow the request from the socket to the controller result.

The request lifecycle is short enough to read end-to-end in App.php, RequestParser.php, Router.php, and the Example controllers.

01Acceptstream_socket_accept()
02ParseRequest::fromResource()
03MatchRouter::processRoutes()
04ResolveInjector::inject()
05WriteResponse::__toString()
01

Raw socket to Request

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.

02

Routing and parameter capture

Router compares the request method against the HttpMethod enum, walks the registered routes in order, and collects decoded :name path parameters into RouteMatch::params.

03

Controller invocation

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.

04

Result conversion and failures

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:”.

Listen with explicit address and portBLOCKING
<?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.

404
No matching route

App::handleRequest() returns a plain Not Found response.

500
Unhandled throwable

The listen loop catches it and writes “Server Error: …” in the response body.

07 / Operations

Configure the server, then choose how to run it.

The repository ships both direct PHP entry points and container-oriented workflows for development and production images.

Environment variables
  • 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.
Deployment guidance
  • Use php Example/index.php or your own front controller for direct local runs.
  • Because the runtime is single-process and blocking, scale by running multiple container or process instances behind a reverse proxy or platform load balancer.
  • The repository Docker image uses PHP 8.5.10, while the package requirement remains PHP ^8.4.
  • Do not expect long-running concurrent workloads to be multiplexed inside one BraidPHP listener process.
Docker / Compose

Use the checked-in image definition.

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
Podman helpers

Use the repository scripts when Podman is your container runtime.

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
Package install versus repository clone

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.

Public listener limitations

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.

08 / Development and release

Read the source. Run the checks. Publish deliberately.

The contributor workflow is intentionally plain: Composer validation, platform checks, static analysis, coding standards, PHPUnit, coverage, and a small release checklist.

CommandsCI
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.