Every request in the framework is handled by two kernels running in the same PHP process. PHP_SF\System\Kernel is the framework kernel — it wires controllers, translations, templates, and drives the router. App\Kernel is the Symfony kernel — it manages the DI container, bundles, console commands, and everything else Symfony provides. This page explains how they coexist, why both exist, and what each one owns.
The honest answer is the same as why the framework exists on top of Symfony rather than replacing it — there's no point rebuilding what Symfony already does well.
PHP_SF\System\Kernel exists because certain things needed to work differently from Symfony's defaults:
App\Kernel (Symfony) exists because rebuilding the following from scratch would be wasteful and worse:
Both kernels boot in the same process. They share the same PHP memory space, the same Redis connection, the same database connection — everything initialized by the framework kernel is available to Symfony and vice versa.
// vendor/nations-original/php-simple-framework/src/Kernel.php
namespace PHP_SF\System;
final class Kernel
{
private static string $applicationUserClassName = '';
private static string $headerTemplateClassName = header::class;
private static string $footerTemplateClassName = footer::class;
public function __construct()
{
require_once __DIR__ . '/../functions/functions.php';
if ( DEV_MODE === true ) {
if ( function_exists( 'apcu_clear_cache' ) )
apcu_clear_cache();
Debug::enable();
}
$this->setDefaultLocale();
$this->addControllers( __DIR__ . '/../app/Http/Controller' );
$this->addTranslationFiles( __DIR__ . '/../lang' );
$this->addTemplatesDirectory( 'Platform/templates', 'PHP_SF\Templates' );
register_shutdown_function( function () {
rp()->execute();
} );
}
}
This is a plain class — not a Symfony bundle, not a service, not registered in the DI container. It's instantiated directly with new PHP_SF\Kernel() and configured via a fluent builder interface. It has no boot() method, no bundle system, no container compilation step. It does its work in the constructor and returns.
It owns:
Router)// App/Kernel.php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
final class Kernel extends BaseKernel
{
use MicroKernelTrait;
private static bool $isEditorActivated = false;
private static self $instance;
public static function getInstance(): self
{
if ( !isset( self::$instance ) )
self::setInstance();
return self::$instance;
}
private static function setInstance(): void
{
self::$instance = new self(
env( 'APP_ENV' ),
env( 'APP_DEBUG' ) === 'true' || env( 'APP_DEBUG' ) === '1'
);
self::$instance->boot();
}
}
This is a full Symfony kernel extending BaseKernel with MicroKernelTrait. It manages bundles, the DI container, and Symfony's full request/response cycle. It's a singleton accessed via App\Kernel::getInstance().
It owns:
config/bundles.php)config/routes.yaml, SymfonyControllers/)bin/console)var/cache/)The order in which the two kernels boot is strict and intentional. Getting it wrong causes undefined constant errors, missing services, or silent failures.
1. vendor/autoload.php — Composer PSR-4 autoloader
2. functions/functions.php — global helpers (em, ca, rca, s, etc.)
3. config/constants.php — DEV_MODE, LANGUAGES_LIST, APPLICATION_NAME, etc.
4. Debug::enable() — Symfony error handler (DEV_MODE only)
5. config/eventListeners.php — middleware event listener registration
6. Dotenv::bootEnv() — .env variables loaded into $_ENV/$_SERVER
7. new PHP_SF\Kernel() — framework kernel boots
├── APCu cleared (DEV_MODE)
├── default locale set
├── framework controllers registered
├── translation files registered
├── template directories registered
└── shutdown function registered
8. auth::logInUser() — user restored from session
9. Router::init( $kernel ) — route matching and dispatch
├── Route matched → dispatch → exit()
│ (steps 10-11 never run)
└── No match → fall through
10. Kernel::addRoutesToSymfony() — OpenAPI-decorated routes added to Symfony
11. autoload_runtime.php — App\Kernel boots (Symfony)
├── bundles loaded
├── DI container compiled (or loaded from cache)
├── Symfony route matched → Symfony response
└── No match → Symfony 404
The critical insight is step 9 — if the framework matches a route and dispatches it, exit() is called and the process ends. The Symfony kernel never boots for matched framework routes. Steps 10 and 11 only execute when no framework route matched.
This is why the framework is faster for its own routes — a matched framework request never pays the cost of Symfony container compilation or bundle loading.
1. Platform/vendor/autoload.php — framework vendor autoloader
2. functions/functions.php
3. config/constants.php
4. vendor/autoload_runtime.php — project vendor autoloader
5. Dotenv::bootEnv()
6. new PHP_SF\Kernel() — framework kernel boots
└── (same as HTTP, minus locale detection)
7. return function() { — deferred Symfony boot
App\Kernel::getInstance() — Symfony kernel boots
$kernel->boot()
return new Application( $kernel )
}
The framework kernel boots before Symfony in the console context too. This ensures em(), ca(), s(), translation helpers, and all framework globals are available inside console commands. The Symfony Application wraps the Symfony kernel and takes over command execution after both kernels are ready.
1. Platform/vendor/autoload.php
2. functions/functions.php
3. config/constants.php
4. vendor/autoload.php
5. config/eventListeners.php
6. Dotenv::bootEnv()
7. new PHP_SF\Kernel() — framework kernel boots
8. auth::logInUser() — simulate authenticated user for tests
9. restore_error_handler() — undo Symfony Debug handler overrides
10. restore_exception_handler()
11. $GLOBALS['kernel'] = $kernel — kernel available in test classes
The test bootstrap follows the same pattern but adds two steps after framework boot: restoring error and exception handlers (which Symfony's Debug::enable() overrides), and storing the kernel in $GLOBALS for test classes that need direct access.
Because both kernels run in the same PHP process, they share everything that lives in PHP's memory or in external services:
Shared via PHP globals/statics:
auth::$user — authenticated user set by auth::logInUser() is available everywhereRouter::$currentRoute — current matched route accessible from any codeRouter::$routesList — parsed route list shared between framework and addRoutesToSymfony()Response::$activeTemplates — template rendering trackerShared via Redis:
ca() / rca() / aca() available everywhereShared via Doctrine:
em() returns the Doctrine EntityManager from Symfony's DI container — the same connection used by both framework entities and Symfony servicesThis last point is important: em() is not a framework-internal entity manager. It's the Symfony-managed Doctrine EntityManager retrieved from the container:
function em( string $connectionName ): EntityManager
{
return Kernel::getInstance()
->getContainer()
->get( 'doctrine.orm.' . $connectionName . '_entity_manager' );
}
The framework calls into Symfony's container to get the entity manager. This means Doctrine configuration in config/packages/doctrine.yaml applies equally to framework entity operations and Symfony service entity operations.
App\Kernel is a singleton accessed via getInstance(). It's used in several places throughout the framework:
// Get the Symfony DI container
App\Kernel::getInstance()->getContainer()
// Get a service from the container
App\Kernel::getInstance()->getContainer()->get( 'router' )
// Get the project directory
App\Kernel::getInstance()->getProjectDir()
// Check/set CKEditor status (template-specific feature)
App\Kernel::isEditorActivated()
App\Kernel::setEditorStatus( true )
PHP_SF\System\Kernel is not a singleton in the same way — it's constructed once in public/index.php and passed to Router::init(), which stores it internally. Access after boot is via Router's static state rather than a getInstance() call.
Since framework controllers are not Symfony services and aren't instantiated by the DI container, they can't use constructor injection for Symfony services. When a framework controller or middleware needs a Symfony service, it accesses the container directly:
// In a framework controller or middleware
$mailer = App\Kernel::getInstance()
->getContainer()
->get( 'mailer' );
// Or via the router shortcut (used internally by routeLink())
App\Kernel::getInstance()
->getContainer()
->get( 'router' )
->generate( 'route_name', $params );
This is intentionally explicit rather than automatic — framework components are not DI-managed, so service access is opt-in via the container rather than injected automatically. For most game logic this never comes up since the framework's global helpers cover the common cases. For edge cases that genuinely need a Symfony service, direct container access is the escape hatch.
| Responsibility | PHP_SF\System\Kernel | App\Kernel (Symfony) |
|---|---|---|
| Route scanning and caching | ✓ | |
| Request routing and dispatch | ✓ | |
| Middleware execution | ✓ | |
| View rendering | ✓ | |
| Translation loading | ✓ | |
| Session locale detection | ✓ | |
| Redis pipeline flush (shutdown) | ✓ | |
| DI container | ✓ | |
| Bundle management | ✓ | |
| Console commands | ✓ | |
| Doctrine ORM configuration | ✓ | |
| Symfony routing (fallback) | ✓ | |
| Messenger/async workers | ✓ | |
| Web profiler | ✓ | |
| Twig rendering | ✓ | |
| Error pages | ✓ | |
Symfony cache (var/cache/) |
✓ | |
em() entity manager (via container) |
both | both |
| Redis/cache access | both | both |
Calling App\Kernel::getInstance() before Dotenv::bootEnv() — the Symfony kernel constructor reads APP_ENV and APP_DEBUG from environment variables. If bootEnv() hasn't run yet, both will be null and the kernel will boot with incorrect configuration. The load order in public/index.php handles this correctly — don't rearrange it.
Expecting Symfony services in framework controller constructors — framework controllers are instantiated by Router::initializeController() with new ControllerClass( $request ), not by Symfony's DI container. Constructor injection of @services won't work. Use App\Kernel::getInstance()->getContainer()->get() for one-off service access.
Booting App\Kernel manually in tests — the test bootstrap doesn't boot App\Kernel explicitly. It boots on demand via getInstance() when first needed. Calling new App\Kernel() directly in a test bypasses the singleton and creates a second Symfony kernel instance in the same process, which causes container conflicts and duplicate service instantiation.
Clearing only one kernel's cache — the two kernels have separate cache systems. app:cache:clear clears Redis (framework routes, templates, translations). symfony:cache:clear clears var/cache/ (Symfony container, router, Twig). Both must be cleared after deployment. Clearing only one leaves the other stale.
Using PHP_SF\System\Kernel as a singleton — it's constructed once and stored in Router's static state, but it's not a singleton in the getInstance() sense. Don't call new PHP_SF\Kernel() multiple times — construct it once in your entry point and pass it to Router::init(). Constructing it multiple times re-registers shutdown functions and re-scans translation files.