Skip to content

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 replaceablenikic/fast-route sits behind Contract\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 Allow header)/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

bash
composer require jardiscore/app

GitHub: jardisCore/app

Dependencies:

PackagePurpose
jardiscore/kernelThe Koffer (DomainKernel) + ENV packer the bootstrap bridge sits on top of
jardissupport/contractsDomainResponseInterface, ResponseStatus, the response-envelope contract
nikic/fast-route, nyholm/psr7, nyholm/psr7-serverRouting + PSR-7/PSR-17 implementation

The building blocks

ClassResponsibility
RoutesRegistration 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.
AppThe orchestrator. handle(ServerRequestInterface): ResponseInterface (pure, no shared state) · run(): void (builds the request from SAPI globals, handle()s it, emits it).
Config\AppConfigfinal readonly__construct(bool $debug = false). Injected by the bootstrap, never reads ENV itself.
Handler\Response\MapDomainResponseThe canonical envelope mapper: DomainResponseInterface → PSR-7.
Handler\Response\BuildErrorResponseThe one place that assembles {status, data, errors, meta}.
Handler\Error\HandleThrowableThe outermost try/catch boundary.
Handler\Request\ParseJsonBodyThe one JSON-body parser (raw-body invariant).

Registering routes

php
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 domain

Every 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-HEADHEAD is automatically registered for every GET route (unless an explicit HEAD route already exists); the body is suppressed on emit, Content-Length stays.
  • OPTIONS is never added automatically: unregistered → 404 (no path) or 405 (path with other methods).

The envelope — {status, data, errors, meta}

Reference: jardissupport/contractsdocs/response-envelope.md. ResponseStatus (eleven cases, JardisSupport\Contract\Kernel\ResponseStatus) maps 1:1 to the HTTP code:

CaseHTTPCaseHTTP
Success200Forbidden403
Created201NotFound404
NoContent204MethodNotAllowed405
ValidationError400Conflict409
Unauthorized401RuleViolation422
InternalError500
  • 204 (NoContent) is a special case: no body, no Content-Type, a bare empty response.
  • Empty data/errors/meta are coerced to a JSON object ({}), never [].
  • 405 carries an RFC-7231 Allow header: the only case that populates the allowedMethods parameter.
  • 422 (RuleViolation)getData() is serialized unchanged under data; 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), unless AppConfig::$debug === true. The full exception always goes to the injected PSR-3 logger (LogThrowable falls back to error_log() if the logger is null or throws itself). The 500 response is unaffected.

Bootstrap recipe — public/index.php

BuildDomainKernelFromEnv (packer) → DomainKernel (Koffer) → generated domain (new {Domain}($kernel)) → Routes + handlers → Apprun():

php
<?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:

bash
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):

php
$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.