The framework coexists with standard Symfony controllers through a clean directory separation. Symfony controllers live in App/Http/SymfonyControllers/ and are handled entirely by Symfony's routing and DI system. Framework routes that expose an API can be documented automatically via Nelmio API Doc Bundle.
A Symfony controller in this project is a plain PHP class using Symfony's #[Route] attribute from Symfony\Component\Routing\Annotation\Route:
// App/Http/SymfonyControllers/ExampleSymfonyController.php
namespace App\Http\SymfonyControllers;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
final class ExampleSymfonyController
{
#[Route( '/example/symfony', name: 'example_symfony' )]
public function index(): JsonResponse
{
return new JsonResponse(
[ 'key' => 'value' ],
JsonResponse::HTTP_OK
);
}
}
Symfony picks up this directory via config/routes.yaml:
controllers:
resource:
path: ../App/Http/SymfonyControllers/
namespace: App\Http\SymfonyControllers
type: attribute
Everything Symfony provides is available here — dependency injection, service autowiring, Symfony's event system, response helpers, and the full Symfony request/response cycle. These controllers are completely invisible to the framework router and only ever handled by Symfony.
| Use framework controllers for | Use Symfony controllers for |
|---|---|
| Game logic endpoints | Infrastructure endpoints |
| Player-facing pages | API documentation UI |
| Authenticated game actions | Webhook receivers |
| Anything using middleware composition | Endpoints needing Symfony DI autowiring |
| High-traffic routes that benefit from route caching | One-off utility endpoints |
The practical rule: if it's part of the game and benefits from the framework's routing performance, middleware system, or view rendering — use a framework controller. If it's infrastructure, tooling, or genuinely needs Symfony's DI container — use a Symfony controller.
The project uses Nelmio API Doc Bundle for API documentation. It serves:
/api/docs — interactive Swagger UI/api/docs.json — raw OpenAPI JSON specConfiguration lives in config/packages/nelmio_api_doc.yaml:
nelmio_api_doc:
documentation:
info:
title: Nations Original API
version: 1.0.0
areas:
path_patterns:
- ^/api(?!/docs$) # all /api/* routes except /api/docs itself
By default, framework routes are invisible to Nelmio because they're not registered in Symfony's router. Kernel::addRoutesToSymfony() bridges this gap — it adds framework routes that have OpenAPI #[Response] attributes to Symfony's route collection so Nelmio can discover and document them:
// Called in public/index.php after Router::init()
Kernel::addRoutesToSymfony();
Only framework routes decorated with #[Response] attributes are added — routes without OpenAPI attributes are ignored. This keeps the Symfony route collection clean and means you opt in to documentation per route rather than exposing everything automatically.
Add OpenAPI attributes from OpenApi\Attributes to any framework controller method you want documented:
use OpenApi\Attributes as OA;
use PHP_SF\System\Attributes\Route;
use PHP_SF\System\Classes\Abstracts\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
final class PlayerApiController extends AbstractController
{
#[OA\Response(
response: 200,
description: 'Returns player data',
content: new OA\JsonContent(
properties: [
new OA\Property( property: 'id', type: 'integer' ),
new OA\Property( property: 'login', type: 'string' ),
new OA\Property( property: 'email', type: 'string' ),
]
)
)]
#[OA\Response(
response: 404,
description: 'Player not found'
)]
#[Route( url: 'api/players/{id}', httpMethod: 'GET', middleware: auth::class )]
public function get_player( int $id ): JsonResponse
{
$player = User::find( $id );
if ( $player === null )
return $this->notFound( [ 'error' => 'Player not found.' ] );
return $this->ok( $player->jsonSerialize() );
}
}
When addRoutesToSymfony() runs, it detects the #[Response] attribute, converts the route URL from framework syntax to Symfony syntax, and registers it with Symfony's router under the same route name — making it visible to Nelmio.
Framework routes use {param} syntax (or the deprecated {$param}). Symfony's router uses {param} too, but the internal conversion in addRoutesToSymfony() handles any legacy syntax:
// Internal conversion in Kernel::addRoutesToSymfony()
$route['url'] = str_replace( '{$', '/{', $route['url'] );
This means a framework route defined as api/players/{$id} (old syntax) becomes /api/players/{id} in Symfony's route collection. Once the {$param} deprecation is complete, this conversion becomes a no-op.
In production (DEV_MODE = false), the OpenAPI attribute check result per route is cached in Memcached:
// From Kernel::addRoutesToSymfony()
if ( DEV_MODE || ( $OAResponseAttrs = mca()->get( "cache:oa_response_attrs:$routeName" ) ) === null ) {
$OAResponseAttrs = ( new ReflectionClass( $route['class'] ) )
->getMethod( $route['method'] )
->getAttributes( Response::class );
$OAResponseAttrs = !empty( $OAResponseAttrs );
if ( DEV_MODE === false )
mca()->set( "cache:oa_response_attrs:$routeName", $OAResponseAttrs );
}
The reflection check (does this route have a #[Response] attribute?) happens once per route per Memcached lifetime. In DEV_MODE it runs on every request so newly added OpenAPI attributes are picked up immediately without a cache clear.
Note this uses mca() (Memcached) specifically rather than ca() — this is one of the few places in the framework that targets a specific cache adapter directly.
Standard Symfony controllers in SymfonyControllers/ are discovered by Nelmio automatically through Symfony's normal routing — no special registration needed. Just add OpenAPI attributes as you normally would in any Symfony project:
// App/Http/SymfonyControllers/ExampleSymfonyController.php
use OpenApi\Attributes as OA;
use Symfony\Component\Routing\Annotation\Route;
final class ExampleSymfonyController
{
#[OA\Get(
path: '/example/symfony',
description: 'Example endpoint',
responses: [
new OA\Response(
response: 200,
description: 'Success',
content: new OA\JsonContent(
properties: [
new OA\Property( property: 'key', type: 'string' )
]
)
)
]
)]
#[Route( '/example/symfony', name: 'example_symfony' )]
public function index(): JsonResponse
{
return new JsonResponse( [ 'key' => 'value' ] );
}
}
With the application running, the Swagger UI is available at:
https://127.0.0.1:7000/api/docs
And the raw OpenAPI JSON spec at:
https://127.0.0.1:7000/api/docs.json
The UI is served by Nelmio and requires Twig — both are included in the template project's dependencies.
Framework routes are matched before Symfony routes on every request. If a framework route and a Symfony controller route share the same URL, the framework route wins silently — the Symfony controller is never reached for that URL.
Keep URLs cleanly separated: framework routes own game logic paths, Symfony routes own infrastructure paths (/api/docs, /_profiler, /_error, etc.).
Wrong #[Route] import in SymfonyControllers — Symfony controllers must use Symfony\Component\Routing\Annotation\Route, not PHP_SF\System\Attributes\Route. Using the framework's Route attribute in a Symfony controller means the route is invisible to both the framework router (wrong directory) and Symfony's router (wrong attribute). The controller silently does nothing.
Placing Symfony controllers in App/Http/Controller/ — the framework scans this directory for PHP_SF\System\Attributes\Route attributes. A Symfony controller placed here is scanned, ignored by the framework router, and also not picked up by config/routes.yaml which only scans SymfonyControllers/. Use the correct directory for each type.
Expecting OpenAPI docs to update without cache clear — in production, the OpenAPI attribute check is cached in Memcached. Adding or removing #[Response] attributes from framework routes won't be reflected in the docs until Memcached cache is cleared via app:cache:clear.
Using Symfony DI autowiring in framework controllers — framework controllers are instantiated directly by the router, not by Symfony's DI container. Constructor injection of Symfony services won't work in framework controllers. Use the global helper functions (em(), ca(), s()) or access the container via App\Kernel::getInstance()->getContainer() for one-off service access.
Not running both cache clears after deployment — Symfony's route collection (which includes the OpenAPI-registered framework routes) is part of Symfony's container cache in var/cache. Changes to framework routes with OpenAPI attributes require both symfony:cache:clear (to rebuild the container) and app:cache:clear (to clear the Memcached OpenAPI attribute cache and Redis route cache).