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.

Maintainers

Package info

github.com/jardisCore/kernel

Homepage

Documentation

pkg:composer/jardiscore/kernel

Transparency log

Statistics

Installs: 70

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 2

v2.2.0 2026-08-23 16:52 UTC

README

Build Status Latest Version License: MIT PHP Version PHPStan Level PSR-12 Coverage PSR-11 PSR-14 PSR-16 PSR-18

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.

  • DomainKernelInterface in, done. Every generated {Domain}Context and 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. BuildDomainKernelFromEnv packs a DomainKernel from cascading .env files if you want that; build your own DomainKernel directly 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 ContextResponseDomainResponse 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 a cascading .env tree (templates: docs/env-examples/). It takes the project root (the git-clone target), not a config path — the convention is a fixed config/env subdirectory, one project layout every Jardis project shares (see the projekt-layout-konvention Wissensbasis entry):

use JardisCore\Kernel\Bootstrap\BuildDomainKernelFromEnv;

$packer = new BuildDomainKernelFromEnv();
$kernel = $packer(__DIR__);   // reads <projectRoot>/config/env (+ cascade)

$ecommerce = new Ecommerce($kernel);

If <projectRoot>/config/env does not exist yet, the packer creates it (mkdir, race-safe against a parallel fpm cold start) rather than throwing — an empty or freshly created directory just means "nothing configured yet", same as any other missing ENV key. The packer only throws when creating the directory itself fails (permissions, read-only filesystem).

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_* / KAFKA_* / RABBITMQ_* ENV keys — see docs/env-examples/README.md for the full key reference. 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, not the internal config/env path it reads 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

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 config/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-examples/.env.database.example):

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-examples/.env.database.example): 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
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)).

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 (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 ContextResponseDomainResponseTransformerDomainResponse 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: only jardissupport/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 see DomainKernelInterface.

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.2) Cascading .env loading — used by BuildDomainKernelFromEnv; ^1.2 for addRawKeys() (credential cast exemption)

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:

docs.jardis.io/en/core/kernel

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