jardiscore / app
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.
Requires
- php: >=8.3
- jardiscore/kernel: ^2.0
- jardissupport/contracts: ^2.17
- jardissupport/factory: ^1.0
- jardissupport/response: ^3.0 || ^4.0
- nikic/fast-route: ^1.3
- nyholm/psr7: ^1.8
- nyholm/psr7-server: ^1.1
- psr/http-factory: ^1
- psr/http-message: ^2
- psr/http-server-handler: ^1
- psr/http-server-middleware: ^1
Requires (Dev)
- ext-pdo: *
- jardissupport/auth: ^1.8
- jardissupport/data: ^1.0
- jardissupport/dbquery: ^1.0
- jardissupport/repository: ^1.0
- jardissupport/scheduling: ^1.2
- jardissupport/validation: ^1.5
- jardissupport/workflow: ^5.0
- middlewares/client-ip: ^2.1
- phpstan/phpstan: ^2.0.4
- phpunit/phpunit: ^11.0
- squizlabs/php_codesniffer: ^3.11.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v3.3.0
- v3.2.0
- v3.1.0
- v3.0.0
- v2.1.2
- v2.1.1
- v2.1.0
- v2.0.0
- v1.9.0
- v1.8.1
- v1.8.0
- v1.7.0
- v1.6.0
- v1.5.2
- v1.5.1
- v1.5.0
- v1.4.2
- v1.4.1
- v1.4.0
- v1.3.0
- v1.2.0
- v1.1.11
- v1.1.10
- v1.1.9
- v1.1.8
- v1.1.7
- v1.1.6
- v1.1.5
- v1.1.4
- v1.1.3
- v1.1.2
- v1.1.1
- v1.1.0
- v1.0.3
- v1.0.2
- v1.0.1
- v1.0.0
- dev-develop
- dev-feature/261025_signedinrun-access-token
- dev-feature/261025_signed-in-run
- dev-feature/261025_revoke-session-on-success
- dev-feature/261025_require-right
This package is auto-updated.
Last update: 2026-10-25 12:18:54 UTC
README
Part of Jardis — the Domain-Driven Design platform for PHP. You model your domain; Jardis generates the production-ready hexagonal code.
jardiscore/kernelis 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-routesits behindContract\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
Allowheader)/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-Versionis read and validated exactly once, centrally, against the known versions inAppConfig::$knownVersions(e.g.new AppConfig(knownVersions: ['v2', 'v3'])) and handed on as request attributejardisVersion: no header →''(baseline), a known value → that value. An unknown value (matching is case-sensitive), an empty value or a repeated header is answered400in 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\RequireSessionbinds every request to a session or answers401in the canonical envelope (withWWW-Authenticate: Bearer) before the handler runs; with a valid session the request carries the attributesjardisSubjectandjardisTenant, when the result is aRoleAwareAuthResultInterfacewith a role the rolejardisRole, when it is aRightsAwareAuthResultInterfacewith a non-empty list the rightsjardisRights(list<string>), and, when the authenticator's result is aSessionAwareAuthResultInterface, the bare subject idjardisSubjectIdand the session referencejardisSession(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) answers403in 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\HasRightofjardissupport/auth; the superuser*passes). Fail-closed: absent or unusable rights deny. A guest gets401from the session door first. Details:docs/session.md.HandleThrowableno longer knows an access-denied exception: every unknown exception is a generic500. - Revoke the session after success.
Middleware\RevokeSessionOnSuccess(since 3.1.0) calls the handler first and, when the answer is2xxand the request carries a session reference (RequestAttribute::SESSION), revokes exactly that session through an injected closure (SessionManager::invalidateByReference(...)ofjardissupport/auth); the response is returned unchanged. Not2xxor no reference: nothing is revoked. The access token already issued stays valid until itsexp. 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 throughApp::handle()(soRequireSessionandRequireRightjudge 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 throwsException\SystemRunRefused. The token isdata.access_tokenof the session door's answer (RFC 6749 section 5.1), accepted by the authenticator (ReadAccessToken; the probeReadSessionTokenis deprecated since 3.3.0). Details:docs/session.md. - A required attribute at the door.
Middleware\RequireRequestAttributeanswers403in 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 generatessrc/App/middleware.php); this package only ships the PSR-15 class. Withnew AppConfig(requireSessionGuard: true)theApprefuses to start when a non-anonymous route has noSessionGuardInterfacein its chain (LogicExceptionlistingMETHOD path); the defaultfalsekeeps the old behaviour. - A Raw-Body invariant. The request body is read from
php://inputexactly 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, the422rule-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.0jardissupport/contracts ^2.17jardissupport/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:
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