Skip to content
sempertonPublic

About

The Semperton Framework.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Semperton

Semperton Framework

A small PSR-15 framework built on semperton/routing.
Built once, ready for long-running PHP processes.


Installation

Just use Composer:

composer require semperton/framework

Framework requires PHP 8.2+ and any PSR-7 / PSR-17 implementation.

Usage

Configure the application with the ApplicationBuilder and build() it. Routes take PSR-15 request handlers:

use Nyholm\Psr7\Factory\Psr17Factory;
use Semperton\Framework\ApplicationBuilder;
use Semperton\Framework\RouteCollectorInterface;

$builder = new ApplicationBuilder(new Psr17Factory());

$builder->addErrorMiddleware();

$builder->get('/users/:id:d', $userHandler);
$builder->post('/users', $createHandler, [$authMiddleware]);

$builder->group('/admin', function (RouteCollectorInterface $admin) use ($dashboardHandler) {
    $admin->get('/dashboard', $dashboardHandler, name: 'dashboard');
}, [$authMiddleware], name: 'admin.');

$app = $builder->build();
$response = $app->handle($request);

Middleware runs in the order it was added, the first one is the outermost. Group and route middleware only runs for matched routes, after the global middleware. Unmatched requests result in a 404 / 405 exception (with an Allow header, GET routes include HEAD). OPTIONS requests are answered with a 204 and the Allow header, unless an OPTIONS route exists. These responses only pass the global middleware.

Route params

The routing adds a RouteResult to the request, with the matched route and its decoded params:

use Semperton\Framework\RouteResult;

$result = RouteResult::fromRequest($request); // null before the routing

$result->param('id');            // '42', null if missing
$result->param('page', '1');     // with a default
$result->params;                 // ['id' => '42']
$result->route?->name;           // 'admin.dashboard', empty if unnamed, null without a match
$result->route?->pattern;        // '/admin/dashboard'
$result->allowedMethods;         // methods of the path if the method did not match, e.g. ['GET', 'HEAD', 'OPTIONS']

Params are kept in this single attribute, so they never collide with attributes of other middleware.

Routing runs as the innermost global middleware by default. Call addRoutingMiddleware() to place it earlier, e.g. when a middleware needs the matched route for logging, metrics or access control.

Routes

Paths, placeholders, validators and wildcards are described in the routing docs. Params are decoded, wildcard params may contain / and .., so never use them as file paths unchecked. Add custom validators before the routes using them:

$builder->addRouteValidator('slug', fn (string $value): bool => preg_match('/^[a-z0-9-]+$/', $value) === 1);
$builder->get('/blog/:slug:slug', $articleHandler, name: 'article');

any() maps GET, POST, PUT, PATCH and DELETE, map() takes a custom list:

$builder->any('/rpc', $rpcHandler);
$builder->map(['GET', 'POST'], '/search', $searchHandler);

Build the paths of named routes with the UrlGenerator. It is available before build(), e.g. to register it in the container:

$urls = $builder->getUrlGenerator();
$urls->path('article', ['slug' => 'hello-world']);              // '/blog/hello-world'
$urls->path('article', ['slug' => 'hello-world'], ['page' => 2]); // '/blog/hello-world?page=2'

// absolute, scheme, host and port are taken from the given uri
$urls->url($request->getUri(), 'article', ['slug' => 'hello-world']); // 'https://example.com/blog/hello-world'

Behind a reverse proxy, pass a configured uri or correct the request uri with a trusted proxy middleware.

Trailing slashes

Routes are matched exactly, /users/ does not match /users. Add the TrailingSlashMiddleware before the routing to redirect to the path without trailing slash (301, or 308 to keep the method of non-GET requests). Pass redirect: false to rewrite the request path instead, e.g. for API clients that don't follow redirects:

use Semperton\Framework\Middleware\TrailingSlashMiddleware;

$builder->add(new TrailingSlashMiddleware($responseFactory));

Base path

Serving the application from a sub-directory, set the base path as it appears in the url. Routes are defined without it, requests outside of it result in a 404 and the UrlGenerator includes it:

$builder = new ApplicationBuilder($responseFactory, basePath: '/app'); // https://example.com/app

$builder->get('/blog/:slug', $articleHandler, name: 'article');    // matches '/app/blog/hello-world'
$builder->getUrlGenerator()->path('article', ['slug' => 'hello']); // '/app/blog/hello'

Container

Handlers and middleware can be given as container ids. They are resolved from a PSR-11 container on first use, so unused routes are never instantiated:

$builder = new ApplicationBuilder($responseFactory, $container);

$builder->add(SessionMiddleware::class);
$builder->get('/', IndexHandler::class, [CacheMiddleware::class]);

Ids are not checked at build time, that would load every handler on each PHP-FPM request and not every container reports autowired entries in has(). Catch typos in a test instead, e.g. by requesting every route once.

Error handling

addErrorMiddleware() renders all exceptions as problem details (application/problem+json). Add it first, so it catches the errors of all other middleware. Details and traces are only exposed in debug mode, server errors (5xx) are passed to an optional PSR-3 logger:

use Semperton\Framework\Error\ErrorHandler;

$builder->addErrorMiddleware(new ErrorHandler($responseFactory, debug: true, logger: $logger));

Throw an HttpException to respond with a specific status code. Its message is sent to the client as detail, so never put internal details in it:

throw new HttpNotFoundException('User not found');
// {"type":"about:blank","title":"Not Found","status":404,"detail":"User not found"}

throw new HttpNotFoundException();
// {"type":"about:blank","title":"Not Found","status":404}

The messages of all other exceptions are never exposed, they result in a 500.

Exception Status Headers
HttpBadRequestException 400
HttpUnauthorizedException($challenge) 401 WWW-Authenticate
HttpForbiddenException 403
HttpNotFoundException 404
HttpMethodNotAllowedException($allowedMethods) 405 Allow
HttpConflictException 409
HttpUnprocessableContentException 422
HttpTooManyRequestsException(?$retryAfter) 429 Retry-After if given
HttpServiceUnavailableException(?$retryAfter) 503 Retry-After if given
throw new HttpUnauthorizedException('Bearer realm="api"', 'Token expired');
throw new HttpTooManyRequestsException(retryAfter: 60);

Extend HttpException for other status codes, override getHeaders() for status specific headers:

final class HttpGoneException extends HttpException
{
    protected const STATUS_CODE = 410;
    protected const REASON_PHRASE = 'Gone';
}

Implement the ErrorHandlerInterface for custom error responses.

Request body

The RequestBodyMiddleware parses non-empty application/json (and +json types like application/merge-patch+json) and application/x-www-form-urlencoded bodies, unless the request already has a non-empty parsed body. Invalid JSON results in a 400 response, other content types (e.g. uploads) are left unread. The raw body stays readable, e.g. to verify webhook signatures.

$builder->add(new RequestBodyMiddleware());

The body is read into memory. PHP-FPM limits its size with post_max_size, in workers limit it in the server instead (e.g. request_body { max_size } in Caddy / FrankenPHP, http.max_request_size in RoadRunner).

Reverse proxies

Behind a reverse proxy, REMOTE_ADDR is the address of the proxy and the scheme is that of the proxy connection. The ProxyMiddleware uses the X-Forwarded-For and X-Forwarded-Proto headers, but only of the proxies you trust (ips or ranges). Add it right after the error middleware, so the other middleware see the client:

use Semperton\Framework\Middleware\ProxyMiddleware;

$builder->add(new ProxyMiddleware(['10.0.0.0/8', 'fd00::/8']));

// in handlers and middleware
$clientIp = $request->getAttribute(ProxyMiddleware::CLIENT_IP);

The client ip is the last address of X-Forwarded-For that was not added by a trusted proxy, earlier ones can be forged by the client. Requests from other addresses keep REMOTE_ADDR as client ip, and their scheme is taken from the HTTPS server param, so forged headers are ignored. The scheme is the X-Forwarded-Proto entry of the client, if every proxy appends to both headers, otherwise the last entry, set by the nearest proxy. IPv4 clients of dual-stack sockets (e.g. ::ffff:10.0.0.1) match IPv4 ranges, the client ip is always in canonical form. Forwarded (RFC 7239), X-Forwarded-Host and X-Forwarded-Port are not used, configure the proxy to keep the Host header.

CORS

CORS is a security policy of your application, so there is no built in middleware. Since OPTIONS requests are answered automatically, a global middleware only needs to add the headers. Add it first, so error responses get the headers too:

final class CorsMiddleware implements MiddlewareInterface
{
    /** @param list<string> $origins */
    public function __construct(private readonly array $origins) {}

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $response = $handler->handle($request)->withAddedHeader('Vary', 'Origin');
        $origin = $request->getHeaderLine('Origin');

        if (!in_array($origin, $this->origins, true)) {
            return $response;
        }

        $response = $response->withHeader('Access-Control-Allow-Origin', $origin);

        // preflight, answered with the allowed methods of the path
        if ($request->getMethod() === 'OPTIONS' && $request->hasHeader('Access-Control-Request-Method')) {
            $response = $response
                ->withHeader('Access-Control-Allow-Methods', $response->getHeaderLine('Allow'))
                ->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
                ->withHeader('Access-Control-Max-Age', '600');
        }

        return $response;
    }
}

$builder->add(new CorsMiddleware(['https://example.com']));
$builder->addErrorMiddleware();

Packages like middlewares/cors work as well.

Development

composer test   # phpunit
composer check  # psalm + php-cs-fixer (PER-CS)
composer fix    # apply the code style
composer bench  # phpbench, build and dispatch times

Running

The built application is immutable and keeps no state between requests.

With PHP-FPM, emit the response with the ResponseEmitter:

use Semperton\Framework\ResponseEmitter;

(new ResponseEmitter())->emit($app->handle($request));

// optional, sends the response to the client before the remaining work (logging, destructors ...)
if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
}

The application is built on every PHP-FPM request, so enable OPcache (and preloading) and keep the route definitions free of other work. Building takes about 3 µs per route, see composer bench.

In long-running processes (FrankenPHP worker mode, RoadRunner, Workerman, Swoole ...), build the application once at boot and call handle() for every request. The builder is locked after build(). HEAD requests are routed to GET handlers and return their body. PHP-FPM drops it automatically, in workers the server adapter must not send it. semperton/workerman serves the application with Workerman.

About

The Semperton Framework.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages