App
The HTTP delivery for generated Jardis domains: router, middleware pipeline and the one canonical envelope mapper. Nothing more.
Introduction
Between "an HTTP request arrives" and "$domain->…->process(…) gets called" there was no Jardis answer: building a Jardis-backed HTTP API meant pulling in Laravel or Symfony just for routing. jardiscore/app closes that gap with a deliberately small core:
- FastRoute, but replaceable —
nikic/fast-routesits behindContract\RouterInterface. An implementation detail, not a commitment. - PSR-15 pipeline — global and route middleware, chain-of-responsibility order (global wraps route). Every PSR-15-compliant ecosystem package (auth, CORS, request ID, …) runs without an adapter.
- One canonical mapper — every
DomainResponseInterface, success or error, becomes the same{status, data, errors, meta}JSON envelope. Nobody rebuilds that translation by hand. - One defined boundary-error contract — 404/405 (with a correct
Allowheader)/500 respond in the same envelope as a domain error. - One raw-body invariant — the request body is read exactly once from
php://input; JSON parsing is lazy and never replaces it (the webhook-HMAC case).
Wall Freedom
No generated domain ever imports jardiscore/app: a mechanically verifiable structural property, not a promise. A third-party framework (see examples/symfony-demo/) can satisfy the same envelope contract without knowing about this package. Staying is the default, leaving is the guaranteed freedom.
Installation
composer require jardiscore/appGitHub: jardisCore/app
Dependencies:
| Package | Purpose |
|---|---|
jardiscore/kernel | The Koffer (DomainKernel) + ENV packer the bootstrap bridge sits on top of |
jardissupport/contracts | DomainResponseInterface, ResponseStatus, the response-envelope contract |
nikic/fast-route, nyholm/psr7, nyholm/psr7-server | Routing + PSR-7/PSR-17 implementation |
The building blocks
| Class | Responsibility |
|---|---|
Routes | Registration collector — gathers Route VOs + global middleware. get/post/put/patch/delete/middleware/health. |
Router (implements Contract\RouterInterface) | dispatch(ServerRequestInterface): RouteMatch. Wraps FastRoute; the dispatcher is built lazily on first call. |
App | The orchestrator. handle(ServerRequestInterface): ResponseInterface (pure, no shared state) · run(): void (builds the request from SAPI globals, handle()s it, emits it). |
Config\AppConfig | final readonly — __construct(bool $debug = false). Injected by the bootstrap, never reads ENV itself. |
Handler\Response\MapDomainResponse | The canonical envelope mapper: DomainResponseInterface → PSR-7. |
Handler\Response\BuildErrorResponse | The one place that assembles {status, data, errors, meta}. |
Handler\Error\HandleThrowable | The outermost try/catch boundary. |
Handler\Request\ParseJsonBody | The one JSON-body parser (raw-body invariant). |
Registering routes
use JardisCore\App\Routes;
use Nyholm\Psr7\Factory\Psr17Factory;
$routes = new Routes(new Psr17Factory());
$routes->get('/orders/{id}', $handler, ...$middleware); // MiddlewareInterface ...$middleware, variadic
$routes->post('/orders', $handler);
$routes->put('/orders/{id}', $handler);
$routes->patch('/orders/{id}', $handler);
$routes->delete('/orders/{id}', $handler);
$routes->middleware($globalMiddleware); // applies to every route (outside), registration order
$routes->health('/health'); // GET, always 200 {"status":200} — never touches a domainEvery verb call accepts callable|RequestHandlerInterface; a callable is turned into a Closure immediately at registration. A route handler must return DomainResponseInterface or a PSR-7 ResponseInterface. Anything else throws UnresolvableHandlerResult and propagates to the generic 500 boundary.
- Auto-HEAD —
HEADis automatically registered for everyGETroute (unless an explicitHEADroute already exists); the body is suppressed on emit,Content-Lengthstays. - OPTIONS is never added automatically: unregistered → 404 (no path) or 405 (path with other methods).
The envelope — {status, data, errors, meta}
Reference: jardissupport/contracts → docs/response-envelope.md. ResponseStatus (eleven cases, JardisSupport\Contract\Kernel\ResponseStatus) maps 1:1 to the HTTP code:
| Case | HTTP | Case | HTTP |
|---|---|---|---|
Success | 200 | Forbidden | 403 |
Created | 201 | NotFound | 404 |
NoContent | 204 | MethodNotAllowed | 405 |
ValidationError | 400 | Conflict | 409 |
Unauthorized | 401 | RuleViolation | 422 |
InternalError | 500 |
- 204 (
NoContent) is a special case: no body, noContent-Type, a bare empty response. - Empty
data/errors/metaare coerced to a JSON object ({}), never[]. - 405 carries an RFC-7231
Allowheader: the only case that populates theallowedMethodsparameter. - 422 (
RuleViolation) —getData()is serialized unchanged underdata; for a Rules-Layer violation, typically{rule, messageKey, context}. getEvents()is not part of the client envelope: only the four top-level keys appear.
Error contract
InvalidJsonBody→ 400,errors.message= the (non-sensitive) message.- Any other
Throwable→ 500, generic body (no message/class/trace), unlessAppConfig::$debug === true. The full exception always goes to the injected PSR-3 logger (LogThrowablefalls back toerror_log()if the logger isnullor throws itself). The 500 response is unaffected.
Bootstrap recipe — public/index.php
BuildDomainKernelFromEnv (packer) → DomainKernel (Koffer) → generated domain (new {Domain}($kernel)) → Routes + handlers → App → run():
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use JardisCore\App\App;
use JardisCore\App\Config\AppConfig;
use JardisCore\App\Routes;
use JardisCore\Kernel\Bootstrap\BuildDomainKernelFromEnv;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ServerRequestInterface;
// 1. Build the Koffer from the .env cascade (ENV packer, jardiscore/kernel)
$kernel = (new BuildDomainKernelFromEnv())(__DIR__ . '/..');
// 2. Domain composition (Builder-generated — not part of this package):
// require __DIR__ . '/../App/bootstrap.php';
// $sales = new \Ecommerce\Sales($kernel);
// 3. AppConfig — values come from the kernel ENV, never read by the VO itself.
$config = new AppConfig(debug: (bool) $kernel->env('app_debug'));
// 4. Routes: health endpoint + your own routes. A handler returns a
// DomainResponse (from the BC read facade/process()) OR a PSR-7 response
// — both get mapped automatically.
$routes = new Routes(new Psr17Factory());
$routes->health('/health');
$routes->get('/orders/{id}', static function (ServerRequestInterface $request) {
$id = (string) $request->getAttribute('id');
// return $sales->order()->getOrderById($id); // BC read facade
$factory = new Psr17Factory();
$response = $factory->createResponse(200)->withHeader('Content-Type', 'application/json');
$response->getBody()->write(json_encode(['id' => $id], JSON_THROW_ON_ERROR));
return $response;
});
// 5. App + run(): build the request from SAPI globals, send it through the pipeline, emit it.
$app = new App($routes, $kernel, $config);
$app->run();Start it:
php -d display_errors=Off -S 127.0.0.1:8080 -t public public/index.php
curl -s http://127.0.0.1:8080/health # {"status":200}
curl -s http://127.0.0.1:8080/orders/42 # {"id":"42"}display_errors=Off is deliberate: a production-like run never leaks a stack trace to the client. Exception details are exclusively controlled via AppConfig::$debug (APP_DEBUG).
API versioning
v1 prescribes no mechanism. version is a domain parameter the handler sets from any source (URL prefix or header):
$routes->get('/v2/orders/{id}', static function (ServerRequestInterface $request) use ($sales) {
$id = (string) $request->getAttribute('id');
return $sales->context(GetOrderById::class, ['id' => $id], version: 'v2');
});Deliberately unsolved (N1 — infrastructure responsibility)
Request-body size limits, trusted-proxy/X-Forwarded-* and display_errors=Off are not this package's job: they remain a matter for the web server/FPM/proxy. Typed coercion of path/query/body parameters is the handler's job in v1; for anything beyond trivial casts: Validation.