A small PSR-15 framework built on semperton/routing.
Built once, ready for long-running PHP processes.
Just use Composer:
composer require semperton/framework
Framework requires PHP 8.2+ and any PSR-7 / PSR-17 implementation.
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.
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.
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.
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));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'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.
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.
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).
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.
composer test # phpunit
composer check # psalm + php-cs-fixer (PER-CS)
composer fix # apply the code style
composer bench # phpbench, build and dispatch times
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.