jardiscore / kernel
Infrastructure kernel for PHP — an immutable DomainKernel holding wired-up cache, logger, event dispatcher, HTTP client, database, mailer and filesystem, plus an optional ENV-driven bootstrap. Jardis-generated domain code reaches it through a single interface.
Requires
- php: >=8.2
- ext-pdo: *
- jardissupport/classversion: ^1.0
- jardissupport/contracts: ^2.0
- jardissupport/dotenv: ^1.6
- jardissupport/factory: ^1.0
- psr/container: ^2.0
- psr/event-dispatcher: ^1.0
- psr/http-client: ^1.0
- psr/log: ^3.0
- psr/simple-cache: ^3.0
Requires (Dev)
- ext-amqp: *
- ext-rdkafka: *
- ext-redis: *
- jardis/dev-skills: ^1.0
- jardisadapter/cache: ^1.0
- jardisadapter/dbconnection: ^1.1
- jardisadapter/eventdispatcher: ^1.0
- jardisadapter/filesystem: ^1.0
- jardisadapter/http: ^1.0
- jardisadapter/logger: ^1.0
- jardisadapter/mailer: ^1.0
- jardisadapter/messaging: ^1.0
- jardissupport/secret: ^1.1
- phpstan/phpstan: 2.1.56
- phpunit/phpunit: 11.5.55
- squizlabs/php_codesniffer: 3.13.6
Suggests
- ext-amqp: For RabbitMQ messaging via the Bootstrap-Packer
- ext-rdkafka: For Kafka messaging via the Bootstrap-Packer
- ext-redis: For Redis-based caching, logging and messaging via the Bootstrap-Packer
- jardisadapter/cache: For multi-layer caching (Memory, Redis, Database) via the Bootstrap-Packer
- jardisadapter/dbconnection: For ConnectionPool with read/write splitting, health checks and load balancing
- jardisadapter/eventdispatcher: For PSR-14 event dispatching via the Bootstrap-Packer
- jardisadapter/filesystem: For local and S3 filesystem abstraction via the Bootstrap-Packer
- jardisadapter/http: For PSR-18 HTTP client with handler pipeline via the Bootstrap-Packer
- jardisadapter/logger: For log handlers (File, Console, Redis, Database, etc.) via the Bootstrap-Packer
- jardisadapter/mailer: For SMTP mailer with STARTTLS, AUTH, HTML/text, attachments via the Bootstrap-Packer
- jardisadapter/messaging: For Kafka/RabbitMQ/Redis/Database messaging via the Bootstrap-Packer
- jardissupport/secret: For decrypting secret(...) ENV values via the Bootstrap-Packer; the key comes from the APP_SECRET_KEY process environment variable or from <projectRoot>/support/secret.key
Provides
None
Conflicts
None
Replaces
None
README
Part of Jardis — the Domain-Driven Design platform for PHP. You model your domain; Jardis generates the production-ready hexagonal code (DTOs, Command/Query handlers, repositories, persistence). This package is the runtime the generated code runs on.
The Application-side offer for Jardis-generated domains. One immutable
infrastructure holder (DomainKernel) plus an optional
ENV-driven packer (BuildDomainKernelFromEnv) — you inject the kernel into
the generated domain facade's constructor, nothing more.
Why Jardis Kernel?
Generated Jardis domains are hexagonal all the way down: they depend only on
jardissupport/contracts interfaces, never on a concrete implementation. This
package is one way to satisfy those interfaces at runtime — a minimal,
adapter-agnostic infrastructure bundle.
DomainKernelInterfacein, done. Every generated{Domain}Contextand BC facade takes the DomainKernel via constructor injection — nothing else to wire.- Plain PDO works. Pass a PDO, get going. Need connection pooling later?
Swap in a ConnectionPool. Same
dbConnection()accessor. - Every service is optional. Nullable by design — a DomainKernel without a logger is a perfectly valid DomainKernel; the domain checks and acts accordingly.
- ClassVersion built in. Versioned classes via namespace injection is a
Support-package concern (
jardissupport/classversion); the DomainKernel just carries a container that resolves it. - Immutable kernel. Once built, nothing changes. Safe for application servers, workers, long-running processes.
- ENV wiring is optional, not assumed.
BuildDomainKernelFromEnvpacks a DomainKernel from cascading.envfiles if you want that; build your ownDomainKerneldirectly if you don't.
Installation
composer require jardiscore/kernel
Quickstart
1. Build a DomainKernel
The simplest DomainKernel — no services at all:
use JardisCore\Kernel\DomainKernel; $kernel = new DomainKernel(projectRoot: __DIR__);
A DomainKernel with a database connection — plain PDO is enough:
$kernel = new DomainKernel( projectRoot: __DIR__, connection: new PDO('mysql:host=localhost;dbname=shop', 'root', ''), );
2. Hand it to the generated domain facade
Jardis-generated domains take the DomainKernel via constructor injection — nothing
extends DomainApp anymore (Kernel-Entkopplung, see "Constitutional Note"
below):
$ecommerce = new Ecommerce($kernel); // {Domain} facade, generated by Jardis $response = $ecommerce->order()->placeOrder(['customer' => 'Acme', 'total' => 99.90]); $response->isSuccess(); // true $response->getData(); // ['PlaceOrder' => ['orderId' => 42]] $response->getEvents(); // ['PlaceOrder' => [OrderPlaced {...}]]
The generated {Domain}Context base class (the Naht, handle()/context())
and the ContextResponse → DomainResponse response pipeline are themselves
part of what Jardis generates per domain — see the platform-implementation
skill / docs.jardis.io for the generated-code contract. This package only
provides the DomainKernel these generated classes consume.
3. Or: pack the DomainKernel from ENV
For projects that want zero manual service wiring, BuildDomainKernelFromEnv
assembles a DomainKernel from one .env in the project root (template:
docs/.env.example). It takes the project root (the
git-clone target) — there is no config/ layer: every configuration value of
a Jardis project lives exactly once, in that one file.
use JardisCore\Kernel\Bootstrap\BuildDomainKernelFromEnv; $packer = new BuildDomainKernelFromEnv(); $kernel = $packer(__DIR__); // reads <projectRoot>/.env (+ cascade) $ecommerce = new Ecommerce($kernel);
The cascade is DotEnv's usual one: .env -> .env.local -> .env.{APP_ENV},
plus any load()/load?() include you write yourself. A project root without
a .env is simply "nothing configured yet" — every service degrades to null,
nothing throws, and no directory is created.
The process environment always wins. Since jardissupport/dotenv 1.4.0 a
key already set in the process environment beats the file value (12-factor
III), so the same image runs with a .env on a developer machine and with
environment: entries in production without a code change.
No file at all? Hand the packer the configuration as a string:
$kernel = $packer(__DIR__, $secretsManagerPayload); // .env-formatted string $kernel = $packer(__DIR__, ''); // everything from the environment
The string mode is exclusive against the file: <projectRoot>/.env is not
read, not even per key — the empty string is a valid input meaning "the
process environment is the whole configuration". The project root still matters in string mode: the support/secret.key fallback resolves against it. KEY_FILE= values are read from disk only when the path is absolute (jardissupport/dotenv >= 1.6); a relative _FILE value stays a plain string — COMPOSE_FILE, NGINX_INDEX_FILE and the like are names, not secret mounts.
Encrypted values (secret(...))
The encryption key is looked up in exactly two places, in this order:
APP_SECRET_KEYin the process environment — the master key belongs there and never in a.envfile. A key written into the file would have to be read by the very load it is supposed to unlock, and it would sit in the kernel'senv()array in plaintext.<projectRoot>/support/secret.key— the file fallback, for setups that mount a key rather than export it.
With neither, a secret(...) value cannot be resolved. It is then not
passed on as a cipher: the packer throws
JardisCore\Kernel\Exception\InvalidEnvConfigurationException naming the ENV
key (never the value) before any adapter is built. The key source is
independent of the value source — a key file applies to string input too.
BuildDomainKernelFromEnv wires nine services (cache, logger, event
dispatcher + listener registry, HTTP client, DB connection, mailer,
filesystem, messaging) from DB_* / CACHE_* / LOG_* / HTTP_* / MAIL_* /
REDIS_* / MESSAGING_* / MESSAGING_DB_* / KAFKA_* / RABBITMQ_* ENV keys — see
docs/.env.example for the full key reference, grouped
into the eight blocks (app, database, redis, cache, logger, http,
mail, messaging) the tooling reads. The resulting packed DomainKernel exposes twelve accessors in
total (the nine services above, plus projectRoot(), env(), and
container(), which are not ENV-wired services — see the accessor table
below). projectRoot() returns the project root passed to the packer, which
is also the directory its .env is read from. Every adapter it can use
(jardisadapter/cache, jardisadapter/dbconnection,
jardisadapter/eventdispatcher, jardisadapter/filesystem,
jardisadapter/http, jardisadapter/logger, jardisadapter/mailer,
jardisadapter/messaging) is a composer suggest — not installed, or not
configured, means that accessor stays null on the packed DomainKernel. Nothing
throws for a missing optional service.
DomainKernel — the DomainKernel
$kernel = new DomainKernel( projectRoot: '/path/to/project', // required container: $factory, // ?ContainerInterface cache: $cache, // ?CacheInterface logger: $logger, // ?LoggerInterface eventDispatcher: $dispatcher, // ?EventDispatcherInterface eventListenerRegistry: $registry, // ?EventListenerRegistryInterface httpClient: $client, // ?ClientInterface connection: $pool, // ConnectionPoolInterface|PDO|null mailer: $mailer, // ?MailerInterface filesystem: $filesystemService, // ?FilesystemServiceInterface env: ['db_host' => 'localhost'], // array — private ENV, file values only messaging: $messagingService, // ?MessagingServiceInterface );
| Method | Return |
|---|---|
projectRoot() |
string — root of the project the kernel serves; multiple domains in one project share it |
env(string $key) |
mixed — case-insensitive; private ENV only, no global fallback |
container() |
Factory — always wraps the injected container |
cache() |
?CacheInterface |
logger() |
?LoggerInterface |
eventDispatcher() |
?EventDispatcherInterface |
eventListenerRegistry() |
?EventListenerRegistryInterface — paired with eventDispatcher(); same underlying provider instance (D3) |
httpClient() |
?ClientInterface |
dbConnection() |
ConnectionPoolInterface|PDO|null |
mailer() |
?MailerInterface |
filesystem() |
?FilesystemServiceInterface |
messaging() |
?MessagingServiceInterface — MESSAGING_TRANSPORT=kafka | rabbitmq | redis | database; database is broker-less and reuses the writer PDO from DB_* |
DomainKernel builds nothing and reads no ENV itself — it is a pure,
immutable consumer. All ENV/service-assembly is Bootstrap\BuildDomainKernelFromEnv's
job (or your own equivalent).
eventListenerRegistry() exists so a generated {Agg}EventRouter can
register itself on the domain facade's constructor without any Application
wiring: a fresh build carries new routers automatically. Without a registry in
the DomainKernel, event routing simply stays inactive — no error.
Multi-Domain Service Sharing (explicit)
There is no static registry anymore (Kernel-Entkopplung removed the
first-write-wins ServiceRegistry, G11) — sharing services across domains is
now an explicit choice, not implicit global state:
$kernel = (new BuildDomainKernelFromEnv())(__DIR__); // project root, reads <projectRoot>/.env $ecommerce = new Ecommerce($kernel); // same DomainKernel instance $billing = new Billing($kernel); // same DomainKernel instance -> same connection, cache, ...
A domain that needs its own services builds its own DomainKernel from its own
project root instead of sharing one — one stack, one technical environment
per DomainKernel (ein-stack-eine-technische-umgebung); two databases means two
stacks, not two config directories inside one:
$billingKernel = (new BuildDomainKernelFromEnv())('/path/to/billing-project'); $billing = new Billing($billingKernel);
Advanced: ConnectionPool (optional)
For application servers and read replicas, install jardisadapter/dbconnection
and pass a ConnectionPool instead of plain PDO — either directly, or let
BuildDomainKernelFromEnv build one from DB_READER{N}_HOST ENV keys (see
docs/.env.example, block database):
use JardisAdapter\DbConnection\ConnectionPool; use JardisAdapter\DbConnection\Factory\ConnectionFactory; $factory = new ConnectionFactory(); $kernel = new DomainKernel( projectRoot: __DIR__, connection: new ConnectionPool( writer: $factory->mysql('primary', 'user', 'pass', 'shop'), readers: [ $factory->mysql('replica1', 'user', 'pass', 'shop'), $factory->mysql('replica2', 'user', 'pass', 'shop'), ], ), );
ConnectionPool provides lifecycle management, health checks, round-robin
load balancing, and automatic writer fallback when no readers are available.
Everything downstream ($kernel->dbConnection()) doesn't change.
When BuildDomainKernelFromEnv builds the pool, five optional DB_POOL_*
keys tune its ConnectionPoolConfig (see
docs/.env.example, block database):
DB_POOL_VALIDATE_CONNECTIONS, DB_POOL_HEALTH_CHECK_CACHE_TTL,
DB_POOL_HEALTH_CHECK_NEGATIVE_CACHE_TTL, DB_POOL_LOAD_BALANCING_STRATEGY
(round-robin | random) and DB_POOL_STICKY_WRITER
(stickyWriterDuringTransaction: reads inside an open writer transaction go
to the writer). Setting none of them keeps the adapter defaults — the pool is
built exactly as before; setting any builds an explicit config where only the
set keys deviate. DB_POOL_STICKY_WRITER requires
jardisadapter/dbconnection >= 1.1.0 — with an older version installed the
pool build fails and falls back to a plain PDO attempt on DB_HOST, logged
via error_log. An invalid DB_POOL_LOAD_BALANCING_STRATEGY, or that plain
PDO fallback also failing, now throws InvalidEnvConfigurationException
instead of degrading to null (R1 — see "Error Handling" below).
Error Handling (ENV Bootstrap)
BuildDomainKernelFromEnv and its Handlers follow four rules for every ENV
key they read:
| State | Result |
|---|---|
Key not set, or set to an empty value (KEY=) |
null — the service degrades gracefully |
Key set to an unparsable/invalid value (e.g. HTTP_VERIFY_SSL=maybe, an unknown DB_POOL_LOAD_BALANCING_STRATEGY) |
throws JardisCore\Kernel\Exception\InvalidEnvConfigurationException |
| Key set, service configured, but unreachable (DB/Redis connection fails) | throws InvalidEnvConfigurationException |
Key holds an unresolvable secret(...) value (no APP_SECRET_KEY, no support/secret.key) |
throws InvalidEnvConfigurationException naming the KEY, never the value — before any adapter is built |
| An ENV key the packer does not recognize | ignored |
Boolean ENV keys (HTTP_VERIFY_SSL, DB_POOL_VALIDATE_CONNECTIONS,
DB_POOL_STICKY_WRITER) go through one shared unit,
Bootstrap\Handler\NormalizeEnvBool — DotEnv casts a literal true/false
to bool and a bare 1/0 to int, never a raw string, so a
$value === 'true' comparison silently misreads both (the bug NormalizeEnvBool
replaces). Credential-shaped keys (*_PASSWORD, *_USER, *_SECRET,
*_TOKEN) are registered as DotEnv raw keys before loading — they reach
their handler as the literal string instead of a cast bool/int
(DB_PASSWORD=false stays 'false', not bool(false)).
The one thing that is not a degradation is an unresolved secret(...)
value. Passing a cipher on as if it were a password, a host or a token fails
later, elsewhere, and unintelligibly — so the packer stops at boot, names the
ENV key and points at the two key sources (APP_SECRET_KEY in the process
environment, <projectRoot>/support/secret.key). The exception message never
contains the value.
Architecture
BuildDomainKernelFromEnv Bootstrap-Packer (optional). ENV -> DomainKernel.
├── Handler\BuildConnectionFromEnv mysql | pgsql | sqlite (+ pool)
├── Handler\BuildRedisFromEnv shared fan-out (-> cache + logger)
├── Handler\ExtractPdoFromConnection feeds the cache "db" layer
├── Handler\BuildCacheFromEnv memory | apcu | redis | db
├── Handler\BuildLoggerFromEnv file | console | slack | ... (+redis)
├── Handler\BuildEventListenerProviderFromEnv shared provider (D3)
├── Handler\BuildEventDispatcherFromProvider wraps the same provider
├── Handler\BuildHttpClientFromEnv
├── Handler\BuildMailerFromEnv
├── Handler\BuildFilesystemFromEnv
├── Handler\BuildMessagingFromEnv kafka | rabbitmq | redis | database (R4)
├── Handler\NormalizeEnvBool shared ENV-to-bool unit (R1)
├── Handler\IsEnvValueUnset shared "is this key configured" check (R1)
├── Data\CredentialEnvKeySuffixes *_PASSWORD | *_USER | *_SECRET | *_TOKEN (R1)
├── Data\CacheLayer memory | apcu | redis | db (4 cases)
└── Data\LogHandler file | console | errorlog | syslog |
browserconsole | redis | slack | teams |
loki | webhook | null (11 cases)
DomainKernel Immutable. Constructor injection only.
├── projectRoot() Root of the project this kernel serves.
├── env(key) Case-insensitive. File-only, no global fallback.
├── container() Always Factory. Wraps external container.
├── cache() ?CacheInterface
├── logger() ?LoggerInterface
├── eventDispatcher() ?EventDispatcherInterface
├── eventListenerRegistry() ?EventListenerRegistryInterface (paired with eventDispatcher, D3)
├── httpClient() ?ClientInterface
├── dbConnection() ConnectionPoolInterface | PDO | null
├── mailer() ?MailerInterface
├── filesystem() ?FilesystemServiceInterface
└── messaging() ?MessagingServiceInterface
Everything downstream of the DomainKernel — the generated {Domain}Context Naht
(handle()/context()), resource()/payload()/version()/result(), and
the ContextResponse → DomainResponseTransformer → DomainResponse
pipeline — is generated per domain by Jardis itself (Kernel-Entkopplung: the
generated domain is JardisCore-free; it imports only
jardissupport/contracts). See the platform-implementation skill for that
generated-code contract.
Constitutional Note (Kernel-Entkopplung D4)
As of the Kernel-Entkopplung redesign, jardiscore/kernel sits outside the hexagonal
inner rings — it is Application-layer, not Domain-layer. Concretely:
- The DomainKernel core (
DomainKernel+ the contract interfaces it implements) stays adapter-free: onlyjardissupport/contracts+ PSR interfaces. - The
Bootstrap\sub-namespace legitimately imports concrete adapter packages (jardisadapter/*) — that is Application wiring, not Domain code, and Application code is allowed to depend on concrete infrastructure. - Generated Jardis domains never import anything under
Bootstrap\— they only ever seeDomainKernelInterface.
This mirrors the project-wide rule ("Composition over Inheritance", flat extends only inside generated code) applied one layer up: the Application composes the DomainKernel from adapters; the Domain composes its behaviour from the DomainKernel.
Related Packages
Included dependencies:
| Package | Purpose |
|---|---|
jardissupport/contracts |
Interface contracts (DomainKernelInterface, EventListenerRegistryInterface, etc.) |
jardissupport/classversion |
Versioned class resolution via namespace injection |
jardissupport/factory |
PSR-11 Container + class instantiation |
jardissupport/dotenv (^1.5) |
Cascading .env loading — used by BuildDomainKernelFromEnv; ^1.5 for addRawKeys() (credential cast exemption), process-environment precedence (>= 1.4.0) and loadPrivateFromString() |
Optional (composer suggest, used by Bootstrap\BuildDomainKernelFromEnv):
| Package | Purpose |
|---|---|
jardisadapter/cache |
Multi-layer caching (Memory, APCu, Redis, Database) |
jardisadapter/dbconnection |
ConnectionPool with read/write splitting, health checks, load balancing |
jardisadapter/eventdispatcher |
PSR-14 event dispatching + listener registry |
jardisadapter/filesystem |
Local and S3 filesystem abstraction |
jardisadapter/http |
PSR-18 HTTP client with handler pipeline |
jardisadapter/logger |
PSR-3 logger with file/console/network/queue handlers |
jardisadapter/mailer |
SMTP mailer with STARTTLS, AUTH, HTML/text, attachments |
Documentation
Full documentation, guides, and API reference:
ENV key reference: docs/.env.example — the one
configuration template, in eight blocks.
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