Search by

HTTP delivery layer for Jardis domains — FastRoute routing behind an own interface, a PSR-15 middleware pipeline, and the canonical DomainResponse-to-PSR-7 envelope mapper. No generated domain ever imports it.


README

Build Status Latest Version License: MIT PHP Version PHPStan Level PSR-12 PSR-7 PSR-15

Part of Jardis — the Domain-Driven Design platform for PHP. You model your domain; Jardis generates the production-ready hexagonal code. jardiscore/kernel is the runtime the generated code runs on; this package is the HTTP delivery layer around it.

The Request/Response-Handling offer for Jardis-generated domains. FastRoute behind an own, narrow interface, a PSR-15 middleware pipeline, one canonical DomainResponse → PSR-7 mapper, and a thin bootstrap bridge around the DomainKernel (BuildDomainKernelFromEnv) — nothing more.

Staying is the expected default — leaving is the guaranteed freedom. No Jardis domain ever imports this package (a mechanically checkable structural property, not a promise — see Wall Freedom below); a third-party framework can answer the exact same client-facing envelope contract this package implements, without depending on it at all (see examples/symfony-demo/).

Why Jardis App?

Between "an HTTP request arrives" and "$domain->…->process(…) gets called" there was no Jardis offer — building a Jardis-backed HTTP API meant pulling in Laravel or Symfony just for routing and response mapping. This package closes that gap with a deliberately small core:

  • FastRoute, but swappable. nikic/fast-route sits behind Contract\RouterInterface — an implementation detail, not a commitment.
  • PSR-15 pipeline. Global and per-route middleware, Chain-of-Responsibility ordering (global wraps route-specific), deterministic within each group. Any PSR-15-conformant ecosystem package (auth, CORS, request-id, …) runs without an adapter.
  • One canonical mapper. Every DomainResponseInterface — success or error, domain- or boundary-produced — becomes the same {status, data, errors, meta} JSON envelope (vendor/jardissupport/contracts/docs/response-envelope.md). No consumer builds this translation by hand again.
  • A thin bootstrap bridge. Wraps the DomainKernel's own BuildDomainKernelFromEnv (ENV → DomainKernel) and the Builder-generated domain composition (App/bootstrap.php) with the one thing missing: an HTTP entry point around them.
  • A defined boundary-error contract. 404/405 (with a spec-correct Allow header)/500 all answer in the same envelope as a domain error — a client never has to guess whether "404" came from the domain or from no route matching at all.
  • One reader for the class version. The request header X-Jardis-Version is read and validated exactly once, centrally, against the known versions in AppConfig::$knownVersions (e.g. new AppConfig(knownVersions: ['v2', 'v3'])) and handed on as request attribute jardisVersion: no header → '' (baseline), a known value → that value. An unknown value (matching is case-sensitive), an empty value or a repeated header is answered 400 in the validation form (data.fields[] = {field: "X-Jardis-Version", reason: "invalid", message, messageKey: "request.versionUnknown"}) before any middleware or handler runs. Without configuration only the baseline is reachable, so an existing app behaves as before until a client sends the header.
  • A verified session at the door. Middleware\RequireSession binds every request to a session or answers 401 in the canonical envelope (with WWW-Authenticate: Bearer) before the handler runs; with a valid session the request carries the attributes jardisSubject and jardisTenant, when the result is a RoleAwareAuthResultInterface with a role the role jardisRole, when it is a RightsAwareAuthResultInterface with a non-empty list the rights jardisRights (list<string>), and, when the authenticator's result is a SessionAwareAuthResultInterface, the bare subject id jardisSubjectId and the session reference jardisSession (revoke exactly this session) (JardisSupport\Contract\Auth\RequestAttribute). Details: docs/session.md.
  • The right of a route at the door. Middleware\RequireRight (since 3.0.0) answers 403 in the canonical envelope (errors.messageKey = access.rightMissing, errors.context.right = the right of the route) when the rights of the session (RequestAttribute::RIGHTS) do not hold the right; register it per route behind the session door, with the rights test as a closure (JardisSupport\Auth\Handler\Authorization\HasRight of jardissupport/auth; the superuser * passes). Fail-closed: absent or unusable rights deny. A guest gets 401 from the session door first. Details: docs/session.md. HandleThrowable no longer knows an access-denied exception: every unknown exception is a generic 500.
  • Revoke the session after success. Middleware\RevokeSessionOnSuccess (since 3.1.0) calls the handler first and, when the answer is 2xx and the request carries a session reference (RequestAttribute::SESSION), revokes exactly that session through an injected closure (SessionManager::invalidateByReference(...) of jardissupport/auth); the response is returned unchanged. Not 2xx or no reference: nothing is revoked. The access token already issued stays valid until its exp. Register it per route (logout, tenant switch) behind the session door. Details: docs/session.md.
  • A system run through the same doors. SignedInRun (since 3.2.0) signs in, switches the tenant and calls the target door with the Bearer token, every stage through App::handle() (so RequireSession and RequireRight judge it like a user request). handle($tenant, $signInBody, $method, $path, $body) returns the target's answer unchanged (also 4xx); an empty tenant, a session door that does not answer 2xx or no usable access token in its answer throws Exception\SystemRunRefused. The token is data.access_token of the session door's answer (RFC 6749 section 5.1), accepted by the authenticator (ReadAccessToken; the probe ReadSessionToken is deprecated since 3.3.0). Details: docs/session.md.
  • A required attribute at the door. Middleware\RequireRequestAttribute answers 403 in the canonical envelope (errors.messageKey = request.attributeMissing, errors.context.attribute = the name) when a request attribute a route depends on, e.g. RequestAttribute::TENANT, is missing or empty; register it per route behind the session door. It only checks presence and authorizes nothing. Details: docs/session.md.
  • Anonymous routes and a start-up check. Routes::anonymous() returns a registrar whose verbs register routes that need no session. The middleware itself is wired by the application (the Jardis builder generates src/App/middleware.php); this package only ships the PSR-15 class. With new AppConfig(requireSessionGuard: true) the App refuses to start when a non-anonymous route has no SessionGuardInterface in its chain (LogicException listing METHOD path); the default false keeps the old behaviour.
  • A Raw-Body invariant. The request body is read from php://input exactly once, into a seekable stream; JSON parsing is lazy and never replaces it — getBody() stays byte-identical for the whole pipeline (the webhook-HMAC case).

Wall Freedom

The "no domain depends on this package" guarantee is a structural property, not an example: tests/Acceptance/WallFreedomHarnessTest.php checks every vendored domain's composer dependencies and namespace imports for a dependency on jardiscore/app — none exists, and none may. examples/symfony-demo/ illustrates what that freedom buys a consumer: the same generated domain, answering the same envelope contract, behind Symfony instead of this package's own router — with zero jardiscore/app import anywhere in that demo's composer.json or code.

Installation

composer require jardiscore/app

Quickstart

See docs/getting-started.md for the full, runnable public/index.php recipe (install → ENV/DomainKernel-Bootstrap → domain registration → first route + handler → start → first request — under 15 minutes with a prepared environment, K6). It also covers API versioning (M9), the jardissupport/validation growth path, and the N1 deployment stellschrauben (body-size limits, Trusted-Proxy, display_errors).

Request

What hangs on the HTTP request lives here; the type check of values and the 400 answer do not (since 2.0.0).

Closure Does
Handler\Request\ParseJsonBody the one JSON body parser (InvalidJsonBody -> 400)
Handler\Request\ReadRequestAttribute a request attribute as a string; absent / not a string -> '' (the reader behind a RequireRequestAttribute-guarded route)
Handler\Request\ReadRequestAttributeList a request attribute as list<string> (e.g. RequestAttribute::RIGHTS); absent -> [], present but not a list of strings -> null ("unusable", the caller answers 401, never 500); never throws
Handler\Request\ReadRequestAttributeStrict the same, but absent / not a string / empty -> Exception\RequestAttributeMissing (a RuntimeException), which the error boundary answers as the generic 500 - for an attribute no middleware can guard up front
Handler\Response\AnswerUnsupportedRequest the 500 answer (errors.routes) of a route whose request the generated adapter cannot build: __invoke(list<string> $properties)
$tenant = (new ReadRequestAttribute())($request, RequestAttribute::TENANT);
$rights = (new ReadRequestAttributeList())($request, RequestAttribute::RIGHTS);       // list<string>|null
$scope  = (new ReadRequestAttributeStrict())($request, RequestAttribute::TENANT);   // throws -> 500
return (new AnswerUnsupportedRequest())(['lines', 'meta']);                           // 500 DomainResponse

Request mapper (type check of path, query and body values, the one 400 for a bad request) moved to jardissupport/response ^3.0 || ^4.0 (JardisSupport\Response\Handler\Request\*, ...\Request\Accept\*, JardisSupport\Response\Handler\Answer\AnswerInvalidRequest); see its README. Mapping alt -> neu: CHANGELOG.md [2.0.0].

Examples

  • examples/basic/ — the recipe above wired against a REAL Builder-generated domain (Ecommerce/Sales) over MySQL: GET/POST/PATCH /orders, the 422 rule-violation payload, 404/405/500 boundary cases, and a real PSR-15 ecosystem middleware.
  • examples/symfony-demo/ — the N3 austauschbarkeits-illustration: the same fixture domain behind a Symfony front controller, fulfilling the same envelope contract, without importing this package.

Requirements

  • PHP >=8.3
  • jardiscore/kernel ^2.0
  • jardissupport/contracts ^2.17
  • jardissupport/response ^3.0 || ^4.0

Related Packages

Package Purpose
jardiscore/kernel The DomainKernel (DomainKernel) + ENV-Packer this package's bootstrap bridge builds on
jardissupport/contracts DomainResponseInterface, ResponseStatus, the response-envelope contract doc, the Auth ports and RequestAttribute used by RequireSession (^2.11)
jardissupport/response BuildResponseData / ValidationPayload (the 400 of an unknown X-Jardis-Version), DomainResponse (the 500 of AnswerUnsupportedRequest); owns the request mapper and its 400 since response 1.4

Documentation

Full documentation, guides, and API reference:

docs.jardis.io/en/core/app

License

Jardis is open source under the MIT License. Free for any purpose — commercial or non-commercial.

Jardis — Development with Passion Built by Headgent Development

AI-Assisted Development

This package ships with a skill for Claude Code, Cursor, Continue, and Aider. Install it in your consuming project:

composer require --dev jardis/dev-skills

More details: https://docs.jardis.io/en/skills