PHP-SF sits on top of Symfony rather than replacing it. When the framework router can't find a matching route for a request, it falls through to Symfony's kernel which handles it normally — including returning a proper 404 if Symfony doesn't know the route either. This gives you full access to everything Symfony provides without having to reimplement it in the framework.
The framework handles what it's optimised for — fast attribute-based routing, middleware, PHP class templates, and cache-heavy game logic. Everything else — console commands, database migrations, the web profiler, Mailer, Messenger workers, Twig rendering, the API documentation UI — stays in Symfony and works exactly as documented in the Symfony docs.
The alternative would have been reimplementing all of that from scratch. That's not a trade-off worth making when Symfony already does it well.
Every HTTP request goes through public/index.php. The framework boots first and attempts to match the request URL against its own route list:
HTTP Request → public/index.php
│
├── PHP_SF\System\Kernel boots
│ └── helpers, constants, event listeners, translations loaded
│
├── auth::logInUser()
│ └── user restored from session if authenticated
│
├── Router::init()
│ ├── Route found in framework route list
│ │ └── middleware → controller → response → exit()
│ │ (Symfony never runs for this request)
│ │
│ └── No route found
│ └── falls through (no exit, no exception)
│
├── Kernel::addRoutesToSymfony()
│ └── framework routes with OpenAPI attributes added to Symfony router
│
└── autoload_runtime.php
└── Symfony kernel boots
├── Symfony route found → Symfony handles response
└── No route found → Symfony 404
The critical detail is that Router::init() calls exit() when it matches a route. If it doesn't match, execution simply continues to the next line in public/index.php. Symfony never even boots for matched framework routes — that's part of where the performance gain comes from.
The physical separation between framework and Symfony controllers reflects this architecture:
App/Http/
├── Controller/ ← Framework controllers (#[Route] attribute from PHP_SF)
│ └── ExampleController.php
└── SymfonyControllers/ ← Pure Symfony controllers (#[Route] from Symfony)
└── ExampleSymfonyController.php
Framework controllers use PHP_SF\System\Attributes\Route:
// App/Http/Controller/ExampleController.php
use PHP_SF\System\Attributes\Route;
use PHP_SF\System\Classes\Abstracts\AbstractController;
final class ExampleController extends AbstractController
{
#[Route( url: 'example/page', httpMethod: 'GET' )]
public function example_page(): Response
{
return $this->render( welcome_page::class );
}
}
Symfony controllers use Symfony\Component\Routing\Annotation\Route:
// App/Http/SymfonyControllers/ExampleSymfonyController.php
use Symfony\Component\Routing\Annotation\Route;
final class ExampleSymfonyController
{
#[Route( '/example/symfony', name: 'example_symfony' )]
public function index(): JsonResponse
{
return new JsonResponse( [ 'key' => 'value' ] );
}
}
Symfony picks up SymfonyControllers/ via config/routes.yaml:
controllers:
resource:
path: ../App/Http/SymfonyControllers/
namespace: App\Http\SymfonyControllers
type: attribute
In production, framework controllers are never registered with Symfony's router directly — they're only known to the framework router. This is intentional and keeps the two systems cleanly separated.
In the test environment, PHP_SF\Framework\Routing\PhpSfRouteLoader (tagged as a routing.loader service, active only under when@test in config/routes.yaml) bridges this separation: it registers all PHP_SF routes into Symfony's compiled router cache during cache warming so that Codeception functional tests can exercise framework controllers through the Symfony module's HTTP emulation. See Testing with Codeception for details.
There is one exception to the strict separation — framework routes that have OpenAPI (#[Response]) attributes are added to Symfony's route collection so Nelmio API Doc Bundle can discover and document them:
// App/Kernel.php
public static function addRoutesToSymfony(): void
{
$collection = self::getInstance()
->getContainer()
->get( 'router' )
->getRouteCollection();
foreach ( Router::getRoutesList() as $routeName => $route ) {
// Only routes with OpenAPI Response attributes are added
if ( $OAResponseAttrs === false )
continue;
// URL syntax is converted from framework style to Symfony style
$route['url'] = str_replace( '{$', '/{', $route['url'] );
$route = ( new Route( $route['url'] ) )
->setMethods( [ $route['httpMethod'] ] )
->addDefaults( [ '_controller' => $route['class'] . '::' . $route['method'] ] );
$collection->add( $routeName, $route );
}
}
This is purely for API documentation discovery — these routes are still handled by the framework router for actual requests. Symfony only knows about them for the purpose of generating the OpenAPI spec at /api/docs.
Note that this is also where the old-style {$param} URL syntax is converted to Symfony-style {param} for the Symfony route collection. Once the planned deprecation of {$param} is complete this conversion will no longer be needed.
Anything not handled by the framework belongs in Symfony as normal:
Console commands — extend Symfony\Component\Console\Command\Command and place in App/Command/. Run via php bin/console your:command. The framework kernel is booted inside bin/console so em(), ca(), and all framework helpers are available inside commands.
Database migrations — run via standard Doctrine commands:
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate
Schema management — used in the template project instead of migrations:
php bin/console doctrine:schema:create
php bin/console doctrine:schema:drop -f
php bin/console doctrine:schema:update --force
Fixtures — the template project uses a custom fixture loader, but standard Doctrine fixtures work too. See Database Fixtures.
Twig templates — placed in templates_twig/, configured in config/packages/twig.yaml. Used for pages that benefit from Twig's features or for Symfony bundle views (profiler, API docs UI, error pages).
Web profiler — available in dev environment at /_profiler. Works normally since it's entirely Symfony-side.
API documentation — Nelmio API Doc Bundle serves the OpenAPI UI at /api/docs and the JSON spec at /api/docs.json. Framework routes with #[Response] attributes are included automatically via addRoutesToSymfony().
Messenger workers — run via:
php bin/console messenger:consume default_queue
Cache management — two separate cache clear commands exist because two cache systems exist:
# Framework cache (Redis — routes, templates, translations)
php bin/console app:cache:clear
# Symfony cache (var/cache — container, router, twig)
php bin/console symfony:cache:clear
If the same URL is defined in both a framework controller and a Symfony controller, the framework route wins silently. The Symfony route is never reached for that URL because Router::init() matches it first and calls exit().
There is no warning or error when this happens. If a Symfony route appears to do nothing, check whether a framework route is claiming the same URL.
The practical rule is simple — keep the namespaces separate. Framework routes own everything under the game's domain logic. Symfony routes own infrastructure endpoints (/api/docs, /_profiler, /_error).
Symfony handles error pages for both matched and unmatched routes. For framework routes, errors that bubble up uncaught will be caught by Symfony's error handler since the Symfony kernel is booted after Router::init() in public/index.php.
In dev environment, Symfony's detailed error page with full stack trace is shown. In prod, the error page template from config/routes/framework.yaml is used:
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
prefix: /_error
Customise error pages by creating Twig templates in templates_twig/bundles/TwigBundle/Exception/ following standard Symfony conventions.
Using the wrong #[Route] import — if you import Symfony\Component\Routing\Annotation\Route in a framework controller, the route won't be picked up by the framework router. It may or may not be picked up by Symfony depending on whether the file is in a scanned directory. Always use PHP_SF\System\Attributes\Route in App/Http/Controller/ and Symfony\Component\Routing\Annotation\Route in App/Http/SymfonyControllers/.
Placing Symfony controllers in App/Http/Controller/ — the framework router scans this directory for PHP_SF\System\Attributes\Route attributes. A Symfony controller placed here will be scanned, found to have no framework routes, and ignored by the framework. It will also not be picked up by Symfony because config/routes.yaml only scans SymfonyControllers/. The controller will silently do nothing.
Expecting Router::init() to throw on no match — it doesn't. A missing framework route is not an error — it's a signal to fall through to Symfony. Only Symfony throws a 404 if neither system knows the route.
Clearing only one cache system after deployment — Symfony's container cache and the framework's Redis cache are independent. Clearing only app:cache:clear leaves stale Symfony container and router cache. Clearing only symfony:cache:clear leaves stale framework route and template cache. Run both after every deployment.