This document describes sympress/kernel as a WordPress kernel built on
symfony/dependency-injection. It follows the Symfony component model and maps
the service-container topics from Symfony's documentation to the concrete kernel
implementation in this repository.
The kernel is a Composer package. It depends on the Symfony components used to build, configure, dump, and run the container:
{
"require": {
"symfony/config": "^8.0",
"symfony/console": "^8.0",
"symfony/clock": "^8.0",
"symfony/dependency-injection": "^8.1.8",
"symfony/event-dispatcher": "^8.0",
"symfony/expression-language": "^8.0",
"symfony/filesystem": "^8.0",
"symfony/service-contracts": "^3.6",
"symfony/yaml": "^8.1"
}
}In a WordPress project, sympress/kernel is installed through Composer and
booted from an MU plugin or another early bootstrap file. The kernel builds one
global site container for active MU plugins, plugins, themes, libraries, and
site-level configuration.
<?php
declare(strict_types=1);
use SymPress\Kernel\App;
use SymPress\Kernel\Kernel\SiteKernel;
App::bootKernel(new SiteKernel(dirname(__DIR__, 2)));Symfony describes the dependency injection container as the central place where object construction is standardized. The kernel brings that same idea to WordPress:
- Services are defined in the container instead of plugin bootstrap files.
- Plugins and themes contribute bundles with
Resources/config/orconfig/. - Runtime code uses a compiled Symfony container instead of scattered global singletons.
- WordPress hooks are declared as service tags or attributes and registered during boot.
The kernel is not a copy of Symfony FrameworkBundle. It uses the original
DependencyInjection API, especially ContainerBuilder, Definition,
Reference, DelegatingLoader, PhpFileLoader, YamlFileLoader,
IniFileLoader, GlobFileLoader, DirectoryLoader, ClosureLoader,
BundleExtension, PhpDumper, compiler passes, attribute autoconfiguration,
ServiceLocatorTagPass, ResettableServicePass, and
MergeExtensionConfigurationPass. On top of that, it adds a WordPress-specific
bundle, hook, route, and runtime layer.
The main entry point is SymPress\Kernel\App::boot().
Appcreates the kernel and the initial wrapper container.- The kernel discovers active bundles from Composer metadata,
config/bundles.php, and the legacysymfony_register_bundlesfilter. - If a matching runtime container exists in the cache, that container is used.
- Otherwise the kernel prepares a
ContainerBuilder, registers bundle extensions, loads configuration, compiles the builder, and dumps it withPhpDumperintovar/cache/<env>/kernel. - The runtime container is hydrated with synthetic runtime instances: the kernel, app, site config, WordPress context, and wrapper container.
HookLoaderregisters compiledkernel.hookentries with WordPress.- Bundles and the kernel are considered booted.
During this workflow the kernel emits these WordPress actions:
kernel.bootingkernel.before_container_buildkernel.container_configuredkernel.container_readykernel.bootedkernel.error
Legacy compatibility actions are still emitted:
symfony_before_container_buildsymfony_container_readysymfony_container_loaded
A bundle is described in composer.json > extra.kernel:
The accepted keys and required bundle/root shapes are defined by the
extra.kernel JSON Schema.
{
"type": "wordpress-plugin",
"extra": {
"kernel": {
"bundle": "Acme\\Demo\\DemoBundle",
"entry": "demo/demo.php"
}
}
}Dependent bundles can declare requires. A package is loaded as a bundle only
when its requirements are installed and active.
{
"extra": {
"kernel": {
"bundle": "Acme\\MailerPro\\MailerProBundle",
"entry": "mailer-pro/mailer-pro.php",
"requires": ["acme/mailer"]
}
}
}Projects can restrict discovery to package prefixes in the root
composer.json:
{
"extra": {
"kernel": {
"package_prefixes": ["sympress/", "brianvarskonst/"]
}
}
}Composer package discovery writes a small manifest cache below
var/cache/<environment>/kernel. The manifest contains only packages that match
the configured prefixes and declare extra.kernel, so runtime cache hits do not
need to scan every installed Composer package. The manifest is invalidated when
root/installed Composer metadata or a consumed package composer.json changes.
The manifest stores only bundle descriptors (name/type and extra.kernel), never
entire Composer documents. Activation filters are reevaluated on each discovery.
Manual bundles can also be registered:
<?php
return [
Acme\Demo\DemoBundle::class => ['all' => true],
Acme\Demo\DebugBundle::class => ['development' => true],
];Discovery order:
- Composer bundles from active MU plugins, plugins, themes, and libraries.
- Manual bundles from
config/bundles.php. - Legacy bundles from
symfony_register_bundles. - Sorting by type: MU plugin, plugin, theme, library.
A bundle class should stay small:
<?php
declare(strict_types=1);
namespace Acme\Demo;
use SymPress\Kernel\Bundle\AbstractBundle;
final class DemoBundle extends AbstractBundle
{
}AbstractBundle automatically looks for:
Resources/config/config/Resources/translations/DependencyInjection\<BundleName>Extension
Override build(ContainerBuilder $container) only when a bundle needs compiler
passes or very specific container customization. If build() is overridden,
call parent::build($container) when you still want automatic extension
registration.
A bundle can also implement Symfony 8.1 bundle-extension methods directly. When
no classic DependencyInjection\<BundleName>Extension exists, the kernel
creates a BundleExtension and calls configure(), prependExtension(), and
loadExtension() on the bundle.
<?php
declare(strict_types=1);
namespace Acme\Demo;
use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use SymPress\Kernel\Bundle\AbstractBundle;
final class DemoBundle extends AbstractBundle
{
public function configure(DefinitionConfigurator $definition): void
{
$definition->rootNode()
->children()
->scalarNode('api_key')->defaultValue('')->end()
->end();
}
public function loadExtension(
array $config,
ContainerConfigurator $configurator,
ContainerBuilder $container,
): void {
$configurator->import($this->getPath() . '/Resources/config/services.yaml');
$container->setParameter('demo.api_key', $config['api_key']);
}
}Bundles also receive the Symfony lifecycle. setContainer(), boot(), and
shutdown() are called around the runtime container.
The kernel loads configuration in this order:
- Kernel defaults from
packages/kernel/config - Bundle configuration from
Resources/config/andconfig/ - Site configuration from
<project>/config
Site configuration therefore overrides bundle defaults.
Each configuration directory can contain these patterns:
packages/*.{php,yaml,yml,ini}
packages/<env>/*.{php,yaml,yml,ini}
services.{php,yaml,yml,ini}
services_<env>.{php,yaml,yml,ini}
wordpress.{php,yaml,yml,ini}
wordpress_<env>.{php,yaml,yml,ini}
Configuration is read through Symfony's DelegatingLoader:
PhpFileLoaderYamlFileLoaderIniFileLoaderGlobFileLoaderDirectoryLoaderClosureLoaderSymPress\Kernel\Kernel\FileLocator
The kernel FileLocator delegates normal paths to Symfony and resolves bundle
resources such as @DemoBundle/Resources/config/services.yaml through
KernelInterface::locateResource().
A typical bundle service configuration lives in
Resources/config/services.yaml:
services:
_defaults:
autowire: true
autoconfigure: true
public: false
Acme\Demo\:
resource: '../../src/'
exclude:
- '../../src/DemoBundle.php'config/services.yaml is still loaded as a compatibility fallback. From that
directory, the relative service resource is usually ../src/.
The resource definition matters because PHP attributes are applied when Symfony discovers and reflects the affected classes during a service resource scan.
The kernel defines these core parameters:
kernel.project_dir
kernel.environment
kernel.debug
kernel.cache_dir
kernel.build_dir
kernel.share_dir
kernel.logs_dir
kernel.package_prefixes
kernel.package_manager.enabled
kernel.translation_paths
kernel.bundles
kernel.bundles_metadata
kernel.container_class
.kernel.config_dir
Only selected non-sensitive application environment variables are exposed as
parameters. The default allowlist covers operational values such as
APP_ENV, APP_RUNTIME_ENV, APP_CACHE_DIR, APP_BUILD_DIR,
KERNEL_PACKAGE_PREFIXES, SYMPRESS_KERNEL_BUILD_ID, and
SYMPRESS_KERNEL_VALIDATE_SOURCE_RESOURCES.
Typical request and PHP server values such as HTTP_*, REQUEST_*,
SCRIPT_*, PHP_*, and DOCUMENT_* are intentionally excluded. WordPress and
database values are not imported by wildcard; only explicit non-sensitive names
such as WP_ENV, WP_ENVIRONMENT_TYPE, and WORDPRESS_ENV are allowed because
container dumps/cache files can persist parameter values.
Secret-looking names are denied even when they would otherwise match an allowed
prefix. This includes names containing AUTH, CERT, CREDENTIAL, KEY,
PASS, PASSWORD, PRIVATE, SALT, SECRET, or TOKEN.
Sites can allow additional non-sensitive values through KERNEL_ENV_PARAMETERS
as a comma-separated list:
KERNEL_ENV_PARAMETERS=APP_PUBLIC_API_BASE_URL,APP_BUILD_CHANNELExample:
services:
Acme\Demo\Service\ApiClient:
arguments:
$baseUrl: '%env.app_public_api_base_url%'Percent signs in imported string values are escaped so Symfony does not accidentally interpret them as parameter placeholders.
The kernel follows Symfony's service container rules. Constructor injection is the default, and explicit service definitions are reserved for the places where the container needs extra information.
final class NewsletterHandler
{
public function __construct(
private readonly NewsletterService $newsletter,
private readonly LoggerInterface $logger,
) {
}
}With _defaults.autowire: true, Symfony reads constructor type declarations and
injects matching services automatically. The resource scan shown above is enough
for normal classes:
services:
_defaults:
autowire: true
autoconfigure: true
Acme\Demo\:
resource: '../../src/'
exclude:
- '../../src/DemoBundle.php'Autowiring is deterministic. If an argument is type-hinted with a concrete class, Symfony uses the service whose ID is that class name. If the argument is type-hinted with an interface, there must be exactly one matching alias or a more specific named autowiring alias.
namespace Acme\Demo\Report;
final class ReportController
{
public function __construct(
private readonly ReportBuilder $builder,
) {
}
}services:
Acme\Demo\Report\:
resource: '../../src/Report/'The ReportController service can be built because ReportBuilder is also
registered as a service in the scanned namespace.
When a constructor asks for an interface, point that interface at the default implementation:
namespace Acme\Demo\Contract;
interface FormatterInterface
{
public function format(array $payload): string;
}namespace Acme\Demo\Formatter;
use Acme\Demo\Contract\FormatterInterface;
final class HtmlFormatter implements FormatterInterface
{
public function format(array $payload): string
{
return '<pre>' . esc_html(wp_json_encode($payload)) . '</pre>';
}
}services:
Acme\Demo\Contract\FormatterInterface:
alias: Acme\Demo\Formatter\HtmlFormatterServices are private by default. Mark services public only when they are real entry points that WordPress glue code, tests, CLI commands, or compatibility layers intentionally fetch from the container.
services:
Acme\Demo\Admin\SettingsPage:
public: trueIf several services implement the same interface, use named autowiring aliases.
This mirrors Symfony's autowiring behavior and keeps the choice local to the
argument through the #[Target] attribute.
services:
'Acme\Demo\Contract\FormatterInterface $adminFormatter':
alias: Acme\Demo\Formatter\HtmlFormatter
'Acme\Demo\Contract\FormatterInterface $apiFormatter':
alias: Acme\Demo\Formatter\JsonFormatteruse Acme\Demo\Contract\FormatterInterface;
use Symfony\Component\DependencyInjection\Attribute\Target;
final class FormatterSelection
{
public function __construct(
#[Target('adminFormatter')]
private readonly FormatterInterface $adminFormatter,
#[Target('apiFormatter')]
private readonly FormatterInterface $apiFormatter,
) {
}
}For Symfony 8.1 and newer, prefer #[Target] for named autowiring aliases.
Relying only on the parameter name is deprecated. The value passed to
#[Target] is the named alias target, not a service ID.
Autowiring cannot guess scalar values such as strings, integers, arrays, DSNs,
feature flags, or WordPress option names. Use explicit arguments, binds, or the
Symfony #[Autowire] attribute.
parameters:
demo.api_base_url: 'https://api.example.test'
services:
Acme\Demo\Api\ApiClient:
arguments:
$baseUrl: '%demo.api_base_url%'The same choice can be expressed at the constructor argument:
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class ApiClient
{
public function __construct(
#[Autowire(param: 'demo.api_base_url')]
private readonly string $baseUrl,
) {
}
}Use YAML for shared package policy and attributes for values that are tightly bound to the consuming class.
Constructor injection remains preferred. Use method or setter injection for
optional dependencies or dependencies that must be configured after
construction. Symfony's #[Required] attribute marks those methods for
automatic calls during service creation.
use Symfony\Contracts\Service\Attribute\Required;
final class RequiredSummary
{
private ?HtmlFormatter $formatter = null;
#[Required]
public function setFormatter(HtmlFormatter $formatter): void
{
$this->formatter = $formatter;
}
}The equivalent YAML definition remains available:
services:
Acme\Demo\Service\RequiredSummary:
calls:
- setFormatter: ['@Acme\Demo\Formatter\HtmlFormatter']Tags model loosely coupled plugin extensions. Interfaces can declare their
default tag with #[AutoconfigureTag], and implementations can refine the tag
metadata with #[AsTaggedItem].
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag('demo.panel')]
interface PanelInterface
{
public function title(): string;
}use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
#[AsTaggedItem(index: 'primary', priority: 20)]
final class PrimaryPanel implements PanelInterface
{
public function title(): string
{
return 'Primary';
}
}Inject tagged services with #[AutowireIterator]:
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final class PanelSummary
{
public function __construct(
#[AutowireIterator('demo.panel', indexAttribute: 'index')]
private readonly iterable $panels,
) {
}
}The YAML equivalent is:
services:
Acme\Demo\Panel\PrimaryPanel:
tags:
- { name: demo.panel, index: primary, priority: 20 }
Acme\Demo\PanelRegistry:
arguments:
$panels: !tagged_iterator demo.panelService locators are useful for lazy access to a small, known set of services. They are the clean alternative to arrays of service IDs or injecting the whole application container.
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
final class FormatterLocator
{
public function __construct(
#[AutowireLocator([
'html' => HtmlFormatter::class,
'json' => JsonFormatter::class,
])]
private readonly ContainerInterface $formatters,
) {
}
}The kernel uses the same idea internally for WordPress hooks:
HookCompilerPass collects kernel.hook services and registers them through
ServiceLocatorTagPass::register(). Hook services are fetched from the
container only when WordPress executes the hook.
Factories are a good fit for services whose construction depends on WordPress state, legacy APIs, or external SDKs.
services:
Acme\Demo\Calendar\CalendarService:
factory: ['Acme\Demo\Factory\CalendarFactory', 'create']Static factories and service-method factories are also supported:
services:
Acme\Demo\EventSystem:
factory: ['Acme\Demo\EventSystem', 'getInstance']
public: true
Acme\Demo\EventDispatcher:
factory: ['@Acme\Demo\EventSystem', 'getDispatcher']Expensive integrations should be lazy so normal WordPress requests do not materialize unnecessary objects.
use Symfony\Component\DependencyInjection\Attribute\Lazy;
#[Lazy]
final class ExpensiveApiClient
{
}Service closures are available when exactly one service should be created only on demand:
services:
Acme\Demo\Service\ReportBuilder:
arguments:
$clientFactory: !service_closure '@Acme\Demo\Api\ExpensiveApiClient'The hook system already has lazy behavior built in because hook callbacks are closures that fetch the real service from a locator when WordPress calls the hook.
Optional services should be modeled explicitly:
services:
Acme\Demo\Service\OptionalIntegration:
arguments:
$logger: '@?logger'Symfony can also ignore missing optional services in method calls:
services:
Acme\Demo\Service\OptionalIntegration:
calls:
- setProfiler: ['@?Acme\Demo\Profiler\Profiler']For kernel code, a missing optional WordPress service should not break the entire kernel boot. Required services should fail early during compilation.
Symfony services are shared by default. Use shared: false for stateful objects
that must be recreated for each access.
services:
Acme\Demo\RequestScoped\TemporaryBuffer:
shared: falseUse this sparingly in WordPress. Many services already live for only one request, and extra instances can make debugging and hook behavior harder to reason about.
Parent services can bundle common definition parts:
services:
Acme\Demo\AbstractWebhookHandler:
abstract: true
arguments:
$logger: '@logger'
Acme\Demo\StripeWebhookHandler:
parent: Acme\Demo\AbstractWebhookHandler
arguments:
$topic: 'stripe'The kernel fully compiles the container, so Symfony features such as parent services are available whenever the selected loaders and definitions support them.
Service decoration is useful when a bundle wants to extend another service's behavior without replacing that service's class.
services:
Acme\Demo\TracingMailer:
decorates: Acme\Mailer\MailerInterface
arguments:
$inner: '@Acme\Demo\TracingMailer.inner'Prefer decoration over global WordPress filters when the change is truly about
service behavior. For WordPress output, hooks, and plugin integrations,
kernel.hook tags are often clearer.
Symfony configurators call a method or callable after construction to finish setting up a service. This is useful when a service cannot reasonably accept all options in its constructor.
services:
Acme\Demo\Service\ConfiguredClient:
configurator: ['Acme\Demo\Factory\ClientConfigurator', 'configure']Prefer constructor injection for new services. Configurators are mostly useful for third-party objects and legacy objects.
Symfony expressions can compute special values from services, parameters, or environment values.
services:
Acme\Demo\Service\ApiClient:
arguments:
$dsn: '@=service("Acme\\Demo\\Config\\ApiConfig").dsn()'In kernel packages, expressions should be rare. Small factory services are usually easier to test and debug.
The kernel registers familiar Symfony-style core services and the runtime bridges needed for WordPress:
kernel
service_container
parameter_bag
file_locator
reverse_container
config_cache_factory
dependency_injection.config.container_parameters_resource_checker
config.resource.self_checking_resource_checker
services_resetter
container.env_var_processor
It also uses synthetic services for runtime objects that the container must not construct on its own:
kernel.container
kernel.config
kernel.context
kernel.kernel
kernel.app
These IDs are registered as synthetic and public before compilation. After the runtime container is created, the kernel injects the real instances. Aliases make those objects injectable by class or interface:
Psr\Container\ContainerInterface
Symfony\Component\DependencyInjection\ContainerInterface
SymPress\Kernel\Container
SymPress\Kernel\SiteConfig
SymPress\Kernel\WpContext
SymPress\Kernel\Kernel\KernelInterface
Symfony\Component\DependencyInjection\Kernel\KernelInterface
SymPress\Kernel\App
Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface
Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface
Symfony\Component\DependencyInjection\ServicesResetterInterface
use SymPress\Kernel\SiteConfig;
use SymPress\Kernel\WpContext;
final class ContextAwareService
{
public function __construct(
private readonly SiteConfig $config,
private readonly WpContext $context,
) {
}
}This follows Symfony's synthetic service concept and applies it to the bridge between the compiled container and the running WordPress application.
Services that implement Symfony\Contracts\Service\ResetInterface are
autoconfigured with the kernel.reset tag and reset through
services_resetter. Service subscribers and service locators are also
autoconfigured with Symfony's standard tags.
The core layer also registers Symfony-adjacent services and aliases:
filesystem, optional event_dispatcher, optional clock,
container.expression_language, PSR/EventDispatcher aliases,
LoggerAwareInterface autoconfiguration, and the usual container.excluded
markers for compiler passes, PHP attributes, enums, and PHPUnit test cases.
The kernel registers its own compiler passes and selected Symfony passes:
HookCompilerPassAddConsoleCommandPassAddBehaviorDescribingTagsPassResettableServicePassMergeExtensionConfigurationPass- Bundle-specific passes through
BundleInterface::build()
HookCompilerPass collects all services tagged with kernel.hook, validates
method, type, priority, and accepted_args, builds a service locator, and sets
the hook metadata on HookLoader.
services:
Acme\Demo\Admin\AdminMenu:
tags:
- { name: kernel.hook, hook: 'admin_menu', method: register }The pass works on definitions, not service instances. That matters because compiler passes run before the runtime container exists.
Hooks stay declarative and container-based.
services:
Acme\Demo\Hook\TextdomainLoader:
tags:
- { name: kernel.hook, hook: 'init', method: load }
Acme\Demo\Hook\ContentFilter:
tags:
- { name: kernel.hook, hook: 'the_content', method: filter, type: filter, priority: 20 }Supported tag attributes:
hook: WordPress hook namemethod: service method, defaults to__invoketype:actionorfilter, defaults toactionpriority: defaults to10accepted_args: optional; when omitted, the kernel reflects the method parameter count
Attributes are validated during compilation. Missing methods, invalid hook
types, and invalid accepted_args values fail early.
The kernel adds a WordPress-specific attribute:
use SymPress\Kernel\Attribute\AsHook;
#[AsHook('plugins_loaded', priority: 9, acceptedArgs: 0)]
final class PluginBootstrap
{
public function __invoke(): void
{
}
}The attribute can also be placed on a method:
final class AdminNotice
{
#[AsHook('admin_notices', priority: 5)]
public function render(): void
{
}
}When method is not set on a method-level attribute, the kernel uses the name
of the annotated method.
The kernel registers Symfony Console commands through
Symfony\Component\Console\Attribute\AsCommand. The attribute is mapped to the
console.command tag through autoconfiguration, and AddConsoleCommandPass
collects the commands.
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
#[AsCommand(name: 'demo:report', description: 'Build a demo report.')]
final class DemoReportCommand extends Command
{
}WpCliConsoleBridge exposes the command through WP-CLI:
wp console demo:reportThe kernel also ships small container tools:
wp console debug:container
wp console debug:container --parameters
wp console lint:container
wp console container:dump --format=yamlBoth debug and production use compiled-container cache hits. A hit loads recorded
config paths from meta.json, validates consumed input signatures, and takes a shared
reader lock; it does not execute config loaders or rebuild discovery globs. Only
misses take the exclusive writer lock and recheck after waiting. Writer metadata
and container publication are atomic, private files use mode 0600, and newly
created cache directories use 0700. Readers keep their lock until the referenced
container is loaded. Under the writer lock, cleanup retains the newest three
owned generations plus a one-hour grace period and never deletes unowned files.
APP_CACHE_DIR is honored by both discovery and compilation. Server values take
precedence over dotenv ENV values, then getenv. Relative values resolve under the
project. The default is var/cache/<environment>/kernel. Existing read-only warm
caches can still be read. An implicit legacy group/world-writable cache migrates to
durable project storage at var/cache-private-<uid>/<environment>/kernel without
executing or modifying old PHP dumps; creation logs the selected path once. This
path is shared by CLI and PHP-FPM even with systemd PrivateTmp. If the project is
the webroot or a cold project cannot be written, the implicit cache uses a
project-specific, owner-checked directory under the system temporary directory,
outside DOCUMENT_ROOT/WP_CONTENT_DIR. Pre-created symlinks, other owners and
group/world-writable directories are rejected. This fallback restores automatic
boot for classic and read-only WordPress projects; it is not a shared deployment
cache when CLI and FPM have different users or PrivateTmp namespaces. An explicit
unsafe APP_CACHE_DIR still fails with an actionable diagnostic. Provision an
explicit durable private cache with
the same CLI/PHP-FPM identity and mode 0700, run production cache warmup and
lint:container under that identity, then verify a request before switching traffic.
Keep configured cache parents trusted and outside DOCUMENT_ROOT/WP_CONTENT_DIR.
Normal validation compares modification time and size for root/package Composer
metadata and consumed configuration, including imports. Warm requests do not read
these file contents. Container metadata and discovery descriptors are atomic JSON
files (meta.json and discovery-packages.json), so their replacement by a CLI
writer is visible to existing FPM workers even with OPcache timestamp validation
disabled. Unchanged mutable and immutable readers do not invalidate OPcache.
Compiled containers remain PHP files with generation-specific names; writers
invalidate only the compiled PHP file they publish. Legacy meta.php and
discovery-packages.php are ignored and rebuilt once without executing them.
Missing recorded config signatures force a rebuild.
Edits preserving both size and modification time require a cache clear or explicit
SYMPRESS_KERNEL_CONTENT_HASHES=1. That opt-in mode hashes consumed inputs and debug
source files, detects those edits, and has a distinct cache identity. Recorded
config directory entries detect newly added/deleted config files. Debug tracks source
metadata by default. Production source changes require a new
SYMPRESS_KERNEL_BUILD_ID, a cache clear, or optional
SYMPRESS_KERNEL_VALIDATE_SOURCE_RESOURCES=1 (mtime/size source checks).
An explicit SYMPRESS_KERNEL_IMMUTABLE_CACHE=1 policy skips per-request metadata,
config, and discovery freshness scans and requires a nonempty
SYMPRESS_KERNEL_BUILD_ID. It is safe only when the entire release snapshot is
immutable and every code/config/Composer change changes that identity. A build
ID alone does not disable normal config validation. Missing identity is an error;
this optimization must never silently hide edits in a mutable installation.
Bundle extensions are pristine prototypes cloned for each builder. Runtime compilation copies the mutable definition/argument graph so console lint/debug can independently compile the source builder without reusing extension state or references to locators created in a different builder.
A failed configuration/compiler/cache write aborts boot; the application resets its boot state and rethrows, without serving a stale container. The kernel error hook remains available and the built-in diagnostic logs class/location and a correlation identifier, excluding raw exception messages. The WordPress/PHP boundary determines the failure response (normally HTTP 500); operators can map it to a maintenance 503/retry policy. An explicit read-only cache miss requires deployment warmup or a writable private cache; implicit cold paths may use the private fallback described above. Configure production PHP to hide exception text and monitor failed readiness before switching traffic.
The kernel exposes only a small allowlist of non-sensitive operational
environment variables as env.* container parameters. Secret-looking names such
as *_SECRET, *_TOKEN, *_KEY, *_PASSWORD, *_AUTH, and *_SALT are
never materialized as parameters because compiled containers and container dumps
can be inspected through filesystem or CLI tooling.
Additional non-sensitive names can be allowed through site config:
$config = new EnvConfig();
// KERNEL_ENV_PARAMETERS="APP_PUBLIC_FLAG,APP_BUILD_CHANNEL"Cache files are written with locking so concurrent requests do not dump the same container at the same time.
Bundles can provide Resources/translations/. The kernel collects those paths
in kernel.translation_paths and registers TranslationLoader as a public
service.
Supported files:
*.en.xlf
*.en.xliff
The loader reads XLIFF files and groups the results by bundle package.
Symfony recommends keeping application code independent from the container. The same rule is even more important in the kernel: services should receive their dependencies through constructors, setters, tagged iterators, or locators.
Allowed container access points are entry points:
App::make()for WordPress glue codeHookLoaderfor lazy hook services- small service locators for intentionally limited service sets
- tests and debugging
Avoid this:
final class BadExample
{
public function __construct(private readonly ContainerInterface $container)
{
}
}Prefer this:
final class GoodExample
{
public function __construct(private readonly MailerInterface $mailer)
{
}
}The kernel provides small equivalents to Symfony's container tooling for the site container:
wp console debug:container
wp console debug:container app
wp console debug:container --parameters
wp console debug:container --env-vars
wp console debug:container --tag=kernel.hook --types
wp console debug:container app --types --show-arguments
wp console lint:container
wp console container:dump --format=yaml
wp console container:dump --format=xml
wp console container:dump --format=phpUseful debug points:
kernel.container_configured, to inspect loaded configuration files.kernel.container_ready, to inspect the runtime container.var/cache/<env>/kernel/meta.json, to inspect fingerprint, class, and cache file metadata.- The showcase plugin screen for attributes, locators, tags, and lazy services.
| Symfony topic | Meaning in the kernel |
|---|---|
| Compiling the Container | On cache miss, the kernel compiles a runtime container and dumps it with PhpDumper. |
| Container Building Workflow | App::boot() provides the workflow: discover, configure, compile, dump, hydrate, register hooks. |
| Configurable Bundles | Classic extensions and Symfony 8.1 BundleExtension methods with configure() and loadExtension() are supported. |
| Service Aliases and Public Services | Supported directly; keep public services limited to real entry points. |
| Autowiring | Standard through _defaults.autowire: true; resource scans are recommended. |
| Method Calls and Setter Injection | Supported directly, including #[Required]. |
| Compiler Passes | Supported directly; bundles can register passes in build(). |
| Configurators | Symfony feature, useful for legacy or third-party objects. |
| Debug Container | debug:container, lint:container, and container:dump are available through wp console; tag, type, argument, and env-var views are supported. |
| Core Services | parameter_bag, event_dispatcher, filesystem, clock, file_locator, reverse_container, config_cache_factory, services_resetter, ExpressionLanguage, and env processors are present when the relevant Symfony components are installed. |
| Service Definition Objects | Relevant for the kernel, bundles, and compiler passes. |
| Expressions | Available, but should be used sparingly; factories are usually clearer. |
| Factories | Supported directly and useful for WordPress-adjacent services. |
| Imports | The kernel automatically imports known configuration patterns from the kernel, bundles, and site. |
| Injection Types | Constructor injection is the default; setter and property injection should be deliberate. |
| Lazy Services | Supported directly, including #[Lazy]; hook resolution is lazy. |
| Optional Dependencies | Supported directly with optional references. |
| Parent Services | Supported because the container is fully compiled. |
| Request Service | No Symfony RequestStack is registered by default; inject WpContext for WordPress context. |
| Service Closures | Supported directly; hook callbacks use the same lazy principle. |
| Service Decoration | Supported directly and cleaner than global hooks for service-level extension. |
| Service Subscribers & Locators | Supported directly; the kernel uses service locators for hook services. |
| Non Shared Services | Supported with shared: false; use sparingly. |
| Synthetic Services | Central to runtime objects such as App, Kernel, Config, and Context. |
| Service Tags | Central to kernel.hook, bundle-specific extension points, and tagged iterators. |
- Symfony DependencyInjection Component
- Symfony Service Container
- symfony/dependency-injection Repository
- Compiling the Container
- Container Building Workflow
- Service Aliases and Public Services
- Autowiring
- Service Method Calls and Setter Injection
- Compiler Passes
- Configurators
- Debugging the Service Container
- Service Definition Objects
- Expression Language
- Factories
- Imports
- Types of Injection
- Lazy Services
- Optional Dependencies
- Parent Services
- Request from the Container
- Service Closures
- Service Decoration
- Service Subscribers & Locators
- Non Shared Services
- Synthetic Services
- Service Tags
Compiler-provided DirectoryResource inputs (for example external ORM entity
paths) participate in mutable freshness checks and generated class identity.
Recursive PHP resources compare file metadata, additions and removals in stable order
(or content hashes with SYMPRESS_KERNEL_CONTENT_HASHES=1);
child symlink files/directories are not followed outside the configured root.
A cold build may compile twice when compiler-added resources change its initial
identity. The second builder is fresh, uses the final kernel.container_class,
and must observe the same resource set before publication. Warm loads do not
compile again. Explicit immutable deployment mode skips these checks under its
required build-ID contract.