Kernel Builder
The Kernel class is the single entry point for bootstrapping an HKM application. It is a fluent builder that accepts configuration, validates everything at boot, and then materializes the pipelines on first use. This page documents every builder method, merge semantics, the build/materialize lifecycle, and the bootstrap pattern for multi-project builds.
Overview: Build vs. Materialize
$kernel = Kernel::configure()
->withBasePath('/app')
->withPorts([DatabasePort::class => new MySQLAdapter(...)])
->withModules([AuthModule::class, InvoiceModule::class])
->build(); // ← compile-only: validates config, reads manifests, no pipelines
// Later, on first use:
$response = $kernel->http()->handle($request); // ← materialize: construct pipelines, wire modules, freeze corebuild()— compile-only. Runs the BootPipeline (validates config, compiles manifests). No pipelines are constructed; no modules are wired. Can be called safely multiple times in tests (it is idempotent once all input is the same).materialize()— called implicitly on the first entry-point call (http(),cli(),workerLoop(), orcontainer()). Constructs the three pipelines, wires every module'sregister()andboot(), binds kernel services, and freezes the core container. Runs exactly once per kernel instance.
Builder Methods
Configuration
withBasePath(string $path): self
Set the application base path. Used to resolve var/, userdata/, and relative paths. Defaults to the working directory if omitted.
$kernel->withBasePath('/home/app');Merge: replaces the previous value.
withProjectPath(string $path): self
Set the active project path (e.g. projects/admin). Per-project runtime state (var/, userdata/) is isolated under this path. When omitted, these fall back to the base path.
Used in multi-project builds where each project has its own configuration and cache.
$kernel->withProjectPath('projects/admin');Merge: replaces the previous value.
Ports (Infrastructure)
withPorts(array<class-string, object|\Closure> $bindings): self
Bind port implementations. A port interface (e.g. DatabasePort) maps to an adapter (e.g. MySQLAdapter instance or a lazy factory closure).
->withPorts([
DatabasePort::class => new MySQLAdapter(config('database')),
CachePort::class => new RedisAdapter(config('cache')),
QueuePort::class => fn(CoreContainer $c) => new RedisQueueAdapter(config('jobs')),
])Lazy factories:
- A
Closure(CoreContainer): objectis resolved (and any connection opened) only on first use. - A pre-built object is bound eagerly as-is.
- Use closures for expensive resources (database connections, Redis clients).
Merge: merges with previous bindings (later calls override earlier ones for the same key). Allows a base bootstrap to bind standard ports and child projects to override them:
$base = Kernel::configure()->withPorts([DatabasePort::class => $standardDb]);
$admin = clone $base; // In real use, store the builder
$admin->withPorts([DatabasePort::class => $adminTenantDb])->build();Security
withSecurity(array $layers): self
Register security gateway layers. Order matters: cheapest first (quick rejections first, expensive ones last). At least one layer is required: with none, BindSecurityStage fails the boot ("No security layers configured").
->withSecurity([
new CsrfTokenLayer(...),
new CustomApiKeyLayer(...), // optional; a plugin may add this
])The kernel ships the CsrfTokenLayer. JWT and API-key validation are provided by an Auth module.
Merge: appends to the previous list. Inherited projects keep base layers and add their own.
withWorkerSecret(string $secret): self
Require every dequeued job payload to be HMAC-signed with this key.
->withWorkerSecret(env('JOB_SIGNING_SECRET'))Defaults to the JOB_SIGNING_SECRET env var if you call this method; stays OFF when the env var is unset. Deliberately does NOT fall back to APP_KEY — turning on job verification is a two-sided change (both producer and consumer must be updated), and silently rejecting existing jobs in the queue is the wrong default.
Merge: replaces the previous value.
withErrorPipeline(ErrorPipeline|callable|\Closure $pipeline): self
Configure error handling (where exceptions are logged, who is notified, etc.).
->withErrorPipeline(
ErrorPipeline::notifiers([
new SlackNotifier(env('SLACK_ERRORS')),
new MailNotifier(config('support.email')),
new DatabaseErrorLogger(),
])
->fallback(new FileNotifier(logs_path('errors.log')))
->rules(['critical' => ['slack', 'mail', 'db', 'file'], 'warning' => ['db', 'file']])
)Or pass a callable that receives the ports array and returns an ErrorPipeline (useful when the pipeline depends on a port):
->withErrorPipeline(fn($ports) => new ErrorPipeline(...))Merge: replaces the previous ErrorPipeline.
Modules
withModules(array<class-string> $modules): self
Register modules to load. Provider class-strings, passed as a list.
->withModules([
AuthModule::class,
InvoiceModule::class,
PaymentModule::class,
])Each module's module.json declares what it solves, what it requires, and what it exposes. The kernel reads these and validates the dependency graph at boot.
Merge: appends and de-duplicates while preserving first-seen order. Allows a base bootstrap to register shared modules and child projects to add their own:
$base = Kernel::configure()->withModules([AuthModule::class]);
$admin = clone $base;
$admin->withModules([InvoiceModule::class])->build(); // [Auth, Invoice]withEssentialModules(array $modules): self
Mark modules as essential: they are registered into every request-scoped container regardless of the route's dependency graph. This is what makes services always available without depending on the route graph.
->withEssentialModules(['tenancy.routing', 'user.management'])Accepts both provider class-strings AND module domains (the solves value). Domain entries must name modules already registered in withModules() and are resolved to their providers at build time — an unknown domain fails the boot with a descriptive error.
Use sparingly. This opts those modules out of on-demand loading and adds their registration cost (plus their requires[] graph) to every request.
When to use:
- Session/cookie management (sessions must be available on every page).
- Tenant scoping (every request needs to know which tenant it belongs to).
- User management for role/permission checks.
When NOT to use:
- Outbound HTTP (only some routes send emails).
- Payment processing (only invoice creation pays for it).
- Batch jobs (they have their own module loading).
Merge: appends and de-duplicates. Class entries are auto-added to withModules() if they are class-strings (contain a backslash).
Routes (Project-level)
withRoutes(array $routes): self
Declare project-level routes. These are routes that belong to the project wiring layer (thin orchestration) rather than to a module.
->withRoutes([
['method' => 'GET', 'path' => '/', 'handler' => 'Shop\\Http\\HomeController@index'],
['method' => 'POST', 'path' => '/contact', 'handler' => 'Shop\\Http\\ContactController@store'],
])Routes are compiled into the route-manifest under the synthetic __project__ scope (which has no module dependency graph). The controller is resolved and its ports autowired, but no module's register() runs for it.
- Use this for thin project pages that orchestrate published module contracts.
- Keep real domain logic inside modules.
Merge: appends, keyed by method + domain (or subdomain) + path: a later route with the same key replaces the earlier one, so a child project can redefine a route its base builder declared, while routes with different keys accumulate.
withRouteGroups(array $source): self
Declare project route groups and source-wide route defaults. A group states once what would otherwise repeat on every route inside it.
->withRouteGroups([
'prefix' => '/api',
'filters' => ['auth'],
'groups' => [
[
'domain' => 'organizer.example.com',
'prefix' => '/dashboard',
'name' => 'organizer.',
'routes' => [
['method' => 'GET', 'path' => '', 'handler' => 'Organizer\\DashboardController@index']
]
]
]
])Inheritance:
prefixis concatenated outward-in.nameis concatenated as a prefix onto each route's name.filtersare merged and de-duplicated by alias (a nested group's filter overrides an outer one).domain/subdomainare most-specific-first (inner overrides outer).
Domain groups: Because domain is part of the compiled route KEY, two domains may each declare GET / with different handlers. This is how one project serves several brands without forking.
Merge: shallow merge — groups arrays accumulate so a base builder's groups stay.
withProjectDomains(array $domains): self
Declare the hosts this project serves (normally from proj.json "domains"). Used to validate route domain groups at boot: grouping routes under a host the project never registered produces routes nothing can reach, so the compiler fails with a descriptive error.
->withProjectDomains(['example.com', 'shop.example.com', '*.tenants.example.com'])A bare "subdomain" group (no domain attached) is never checked — it answers on that label across every domain.
Merge: appends and de-duplicates.
Route Policy (Vetoing Plugin Routes)
withRoutePolicy(array $disable): self
Declare routes a plugin exposes that the project chooses NOT to use. A plugin owns its routes, but the project deploying it stays the final authority.
->withRoutePolicy([
'GET /register', // disable one plugin route (method + path)
'oauth.server', // disable every route the oauth.server module solves()
'GET /mail/demo/*', // disable a whole prefix
])Spec forms:
"METHOD /path"— one exact plugin route."oauth.server"— all routes theoauth.servermodule owns (its entiresolves()domain)."METHOD /prefix/*"— every route matching the prefix, present and future.
Validation: A spec matching no plugin route fails the build (prevents typos).
Timing: Applied to plugin routes BEFORE project routes compile, so a project can disable a plugin route and declare its own on the freed key.
Merge: appends and de-duplicates.
withRouteAllowPolicy(array $only): self
Invert the disable policy: "expose nothing except these" instead of "expose everything except these".
->withRouteAllowPolicy([
'POST /oauth/token',
'GET /oauth/jwks',
'auth.identity', // all routes the auth.identity module solves()
'GET /account/*',
])When the list is non-empty: EVERY plugin route must match a spec or it is dropped. Lets you adopt new plugins confidently — nothing they publish reaches your users without review.
When the list is EMPTY: No allowlist (every plugin route stays). This is the default — an empty list means "no filter" rather than "allow nothing" (the latter would silently delete your application).
Composition: Applied BEFORE the disable policy, so the two work together: allow a module's domain, then subtract the handful of its routes you do not want.
No validation: Unlike a disable spec, an allow spec that matches nothing does NOT fail the boot. Naming routes from a plugin you have not enabled yet is normal and should not break your build.
Merge: appends and de-duplicates.
Lifecycle: Build
When you call build():
Set paths — calls
Paths::setBase()andPaths::setProject()from yourwithBasePath()andwithProjectPath()calls.Resolve error pipeline — if you passed a callable to
withErrorPipeline(), invoke it with the ports array to get the finalErrorPipeline.Construct CoreContainer — a fresh container where ports and kernel services are bound.
Bind ports:
- Closures →
singleton()(lazy, resolved on first use). - Pre-built objects →
instance()(eager, stored as-is).
- Closures →
Run BootPipeline:
- Validation stages: check config, detect conflicts, detect cycles.
- Compilation stages: read every
module.jsonandconfig/*.php, write manifests tovar/cache/manifests/. - Process stages: load module helper files (require_once).
- Validation stages: verify ports, verify security layers.
Check BootStamp — if
BOOT_CACHE=1and the manifest is current, skip compilation (runValidationOnly instead).Resolve essential modules — domain entries in
withEssentialModules()are resolved to their provider classes (only after the manifest is built, somodule.jsonis available).Write BootStamp — if caching is on, record the manifest file signatures and builder inputs.
Set
$built = true— the kernel is ready for entry points.
Lifecycle: Materialize
When you call an entry point (http(), cli(), workerLoop(), or container()) for the first time:
Check if already materialized — if so, return the existing pipeline/container (materialize runs at most once).
Set RuntimeMode —
Http,Cli, orWorkerdepending on which entry point was called.Construct EventBus — the long-lived event dispatcher (subscriptions registered by modules during boot).
Construct WorkerPipeline — the job-execution pipeline.
Construct HttpPipeline — the HTTP request-handling pipeline (with SecurityGateway).
Construct CliPipeline — the CLI command-execution pipeline.
Construct WorkerLoop — the background-job worker loop.
Bind configuration — the compiled
ConfigRepository(from config-manifest.php) into the core container.Bind kernel services —
EventBus, pipelines,UrlGenerator,Scheduler(all singletons).Register scheduler commands —
schedule:listandschedule:run.Wire modules:
- For each module in
withModules(), callnew ProviderClass()and thenprovider->boot($http, $cli, $worker, $eventBus). - Modules register their hooks and event subscriptions on the live pipeline instances.
- For each module in
Freeze CoreContainer — call
freeze()so no new bindings can be registered. Any write now throwsLogicException.Set
$materialized = true— indicate we are ready to handle requests.
Entry Points
All entry points call ensureBuilt() (throw if build was not called), then call materialize() with the appropriate RuntimeMode.
http(): HttpPipeline
Materialize for RuntimeMode::Http. Returns the HTTP pipeline, ready to call handle(Request).
$pipeline = $kernel->http();
$response = $pipeline->handle($request);cli(): CliPipeline
Materialize for RuntimeMode::Cli. Returns the CLI pipeline, ready to call run(argv).
$cli = $kernel->cli();
$exitCode = $cli->run(['hkm', 'list']); // argv[0] is the script nameworkerLoop(): WorkerLoop
Materialize for RuntimeMode::Worker. Returns the worker loop, ready to call run($puller, $maxIterations).
$loop = $kernel->workerLoop();
$loop->run(fn() => $queue->pop(), maxIterations: 0); // 0 = infinitecontainer(): CoreContainer
Materialize for RuntimeMode::Cli (the safest neutral choice). Returns the core container, ready for dependency resolution.
Used in tests and tooling that need to reach kernel services without driving a full entry point.
$container = $kernel->container();
$eventBus = $container->make(EventBus::class);config(): ConfigRepository
Return the compiled configuration (from config-manifest.php), available immediately after build().
Does NOT materialize the kernel. Used by bootstrap code and tooling that need to read configuration without standing up the HTTP/CLI/worker surfaces.
$kernel = $kernel->build();
$dbConfig = $kernel->config()->get('database'); // safe before any entry point
$serviceUrl = $kernel->config()->get('api.external.url');The same ConfigRepository instance is bound into the core container during materialize(), so modules can type-hint it and resolve it via the core container.
RuntimeMode
An enum that tracks which surface the kernel materialized for:
enum RuntimeMode: string {
case Http = 'http';
case Cli = 'cli';
case Worker = 'worker';
}Available via $kernel->mode() after the first entry point call (null until then).
if ($kernel->mode() === RuntimeMode::Http) {
// We are serving HTTP requests
}Freezing the CoreContainer
The core container is frozen at the end of materialize(), after every module's boot() has completed and all hooks/subscriptions are registered. After that:
freeze()is called.isFrozen()returns true.- Any call to
bind(),singleton(),instance(), orextend()throwsLogicException.
Why: Prevents request handlers from accidentally (or maliciously) registering new bindings into the shared app-lifetime container. Under OpenSwoole, a binding registered in one coroutine would leak into all others. Under FPM, it would persist across requests in unexpected ways.
When does this matter?
- A middleware or stage that tries to rebind a port throws an error.
- A background job that wants to override a binding cannot (it receives a fresh
ModuleContainerinstead). - Testing code that tries to rebind something after
build()fails clearly.
Bootstrap Pattern: Single Project
<?php
// app/bootstrap/app.php
use Kernel;
return Kernel::configure()
->withBasePath(dirname(__DIR__))
->withPorts([
DatabasePort::class => new MySQLAdapter(config('database')),
CachePort::class => new RedisAdapter(config('cache')),
])
->withSecurity([new CsrfTokenLayer(...)])
->withErrorPipeline(...)
->withModules([AuthModule::class, InvoiceModule::class])
->build();Bootstrap Pattern: Multi-Project
Each project inherits a base builder and adds its own config:
<?php
// app/bootstrap/base.php — shared base, no build()
return Kernel::configure()
->withBasePath(dirname(__DIR__))
->withPorts([DatabasePort::class => new MySQLAdapter(config('database'))])
->withModules([AuthModule::class]);
// NOT calling build() here
// projects/admin/bootstrap/app.php
$builder = require __DIR__ . '/../../../app/bootstrap/base.php';
return $builder
->withProjectPath(dirname(__DIR__))
->withModules([AdminModule::class])
->build();
// projects/api/bootstrap/app.php
$builder = require __DIR__ . '/../../../app/bootstrap/base.php';
return $builder
->withProjectPath(dirname(__DIR__))
->withModules([ApiModule::class])
->build();Each call to build() compiles manifests with the project-specific routes, domains, and modules.
Common Mistakes
Calling withPorts() After build()
$kernel = $kernel->build();
$kernel->withPorts([LoggerPort::class => $logger]); // ✗ too late, core is frozenAll port bindings must be declared before build(). If you need to swap a port per-request, rebind it into a fresh ModuleContainer inside an after.load stage instead.
Building Without Calling an Entry Point
$kernel = $kernel->build();
// ... but never call $kernel->http() or $kernel->cli()
// Pipelines are not constructed; modules are not wiredThe kernel defers pipeline construction and module wiring to the first entry point. Call an entry point before you need the pipelines.
Storing the Materialized Kernel Across Requests (Swoole)
$kernel = null;
$server->on('start', function() use (&$kernel) {
$kernel = Kernel::configure()->...->build(); // ✓
});
$server->on('request', function($req, $res) use ($kernel) {
$response = $kernel->http()->handle(...); // ✓ (materialize once)
$kernel->requestTeardown(); // ✓ (though currently a no-op)
$res->end($response->body());
});This is correct. The kernel is materialized once per worker, and each request gets a fresh ModuleContainer inside LoadStage.
Source
- Kernel.php — complete builder implementation
- RuntimeMode.php