Controllers are PHP classes that extend AbstractController and contain route handler methods. Each method receives URL parameters, has access to the current request via $this->request, and must return a Response, JsonResponse, or RedirectResponse.
// App/Http/Controller/ExampleController.php
namespace App\Http\Controller;
use PHP_SF\System\Attributes\Route;
use PHP_SF\System\Classes\Abstracts\AbstractController;
use PHP_SF\System\Core\Response;
final class ExampleController extends AbstractController
{
#[Route( url: 'example/page', httpMethod: 'GET' )]
public function example_page(): Response
{
return $this->render( welcome_page::class );
}
}
Controllers must be placed in a directory registered with addControllers() during kernel bootstrap. Subdirectories are scanned recursively — organise controllers however makes sense for your application.
protected function render(
string $view,
array $data = [],
string $pageTitle = null
): Response
Pass the view class name, optional data array, and optional page title:
// Basic render
return $this->render( dashboard_page::class );
// With data passed to the view
return $this->render( user_profile_page::class, [
'user' => User::find( $id ),
'inventory' => $inventory,
'allies' => $allies,
] );
// With custom page title
return $this->render( dashboard_page::class, [], 'Player Dashboard' );
Data passed to render() is available in the view via $this->propertyName — see Views for details on how views consume this data.
If no page title is provided, APPLICATION_NAME from config/constants.php is used as the default.
For API endpoints, return a JsonResponse directly:
use Symfony\Component\HttpFoundation\JsonResponse;
#[Route( url: 'api/player/{id}', httpMethod: 'GET', middleware: auth::class )]
public function get_player( int $id ): JsonResponse
{
$player = User::find( $id );
if ( $player === null )
return new JsonResponse(
[ 'error' => 'Player not found.' ],
JsonResponse::HTTP_NOT_FOUND
);
return new JsonResponse( $player->jsonSerialize() );
}
AbstractController includes JsonResponseHelperTrait which provides shortcut methods for every common HTTP status code, removing the need to remember status code constants:
// 200 OK
return $this->ok( $player->jsonSerialize() );
// 201 Created
return $this->created( $newPlayer->jsonSerialize() );
// 202 Accepted
return $this->accepted();
// 204 No Content
return $this->noContent();
// 400 Bad Request
return $this->badRequest( [ 'error' => 'Invalid parameters.' ] );
// 401 Unauthorized
return $this->unauthorized( [ 'error' => 'Authentication required.' ] );
// 403 Forbidden
return $this->forbidden( [ 'error' => 'Insufficient permissions.' ] );
// 404 Not Found
return $this->notFound( [ 'error' => 'Resource not found.' ] );
// 406 Not Acceptable
return $this->notAcceptable( [ 'error' => 'Invalid input.' ] );
// 422 Unprocessable Entity
return $this->unprocessableEntity( [ 'errors' => $validationErrors ] );
All methods accept an optional $data array and optional $headers array:
return $this->ok(
data: $player->jsonSerialize(),
headers: [ 'X-Player-Level' => $player->getLevel() ]
);
All redirect methods come from RedirectTrait. See Sessions & Redirects for full details — this section covers the API surface.
// By route name
return $this->redirectTo( 'login_page' );
// By URL path
return $this->redirectTo( '/auth/login' );
// With path parameters
return $this->redirectTo( 'user_profile', withParams: [ 'id' => $user->getId() ] );
// With errors
return $this->redirectTo( 'register_page', errors: [
'Email already in use.'
] );
// With typed errors
return $this->redirectTo( 'register_page', errors: [
RedirectResponse::ALERT_DANGER => 'Email already in use.',
] );
// With success message
return $this->redirectTo( 'dashboard', messages: [
RedirectResponse::ALERT_SUCCESS => 'Registration successful. Welcome!'
] );
// Back to referring page
return $this->redirectBack();
// Back with validation errors
return $this->redirectBack( errors: $errors );
The Symfony Request object is available via $this->request on every controller instance:
// GET parameters (?page=2&sort=asc)
$page = $this->request->query->get( 'page', 1 );
$sort = $this->request->query->get( 'sort', 'desc' );
// POST parameters (form submission or JSON body)
$email = $this->request->request->get( 'email' );
$password = $this->request->request->get( 'password' );
// Uploaded files
$avatar = $this->request->files->get( 'avatar' );
// Request headers
$token = $this->request->headers->get( 'X-Auth-Token' );
// Client IP
$ip = $this->request->getClientIp();
// HTTP method
$method = $this->request->getMethod();
// Check if request is XMLHttpRequest (Ajax)
$isAjax = $this->request->isXmlHttpRequest();
JSON request bodies are automatically merged into $this->request->request during the Request object construction — API endpoints that receive JSON payloads use the same $this->request->request->get() interface as form submissions.
If multiple routes in a controller share setup logic — loading a repository, verifying a resource exists, checking a precondition — put it in the constructor:
final class GuildController extends AbstractController
{
private EntityRepository $guildRepository;
public function __construct( protected ?Request $request )
{
parent::__construct( $request );
$this->guildRepository = em( 'connection_name' )->getRepository( Guild::class );
}
#[Route( url: 'guild/{id}', httpMethod: 'GET', middleware: auth::class )]
public function guild_page( int $id ): Response
{
$guild = $this->guildRepository->find( $id );
if ( $guild === null )
return $this->redirectTo( 'home_page', errors: [
RedirectResponse::ALERT_DANGER => 'Guild not found.'
] );
return $this->render( guild_page::class, [ 'guild' => $guild ] );
}
#[Route( url: 'guild/{id}/members', httpMethod: 'GET', middleware: auth::class )]
public function guild_members( int $id ): Response
{
$guild = $this->guildRepository->find( $id );
if ( $guild === null )
return $this->redirectTo( 'home_page' );
return $this->render( guild_members_page::class, [
'guild' => $guild,
'members' => $guild->getMembers(),
] );
}
}
The constructor must call parent::__construct( $request ) — the parent stores the request object as $this->request.
For resource controllers that follow a standard CRUD pattern, implement CrudControllerInterface:
use App\Abstraction\Interfaces\CrudControllerInterface;
final class PlayerResourceController extends AbstractController implements CrudControllerInterface
{
#[Route( url: 'api/players', httpMethod: 'POST', middleware: auth::class )]
public function create(): JsonResponse
{
// create and return 201
return $this->created( $player->jsonSerialize() );
}
#[Route( url: 'api/players/{id}', httpMethod: 'GET', middleware: auth::class )]
public function read( int $id ): JsonResponse
{
$player = User::find( $id );
return $player !== null
? $this->ok( $player->jsonSerialize() )
: $this->notFound( [ 'error' => 'Player not found.' ] );
}
#[Route( url: 'api/players', httpMethod: 'GET', middleware: auth::class )]
public function readAll(): JsonResponse
{
return $this->ok(
array_map(
fn( User $p ) => $p->jsonSerialize(),
User::findAll()
)
);
}
#[Route( url: 'api/players/{id}', httpMethod: 'PATCH', middleware: auth::class )]
public function update( int $id ): JsonResponse
{
// update and return 200
return $this->ok( $player->jsonSerialize() );
}
#[Route( url: 'api/players/{id}', httpMethod: 'PUT', middleware: auth::class )]
public function replace( int $id ): JsonResponse
{
// replace and return 200
return $this->ok( $player->jsonSerialize() );
}
#[Route( url: 'api/players/{id}', httpMethod: 'DELETE', middleware: auth::class )]
public function delete( int $id ): JsonResponse
{
// delete and return 204
return $this->noContent();
}
}
CrudControllerInterface enforces the method signatures and expected return types — useful for keeping API controllers consistent across the codebase.
A single controller method can return different response types based on conditions:
#[Route(
url: 'example/page/{response_type}',
httpMethod: 'GET',
middleware: [
custom::class => [
all::class => [ auth::class ],
any::class => [ api_example::class, admin_example::class ]
]
]
)]
public function example_route( string $response_type ): Response|RedirectResponse|JsonResponse
{
if ( $response_type === 'response' )
return $this->render( example_page::class );
if ( $response_type === 'redirect' )
return $this->redirectTo( 'example_route', withParams: [
'response_type' => 'response'
] );
return $this->ok( [ 'status' => 'ok' ] );
}
Declare all possible return types in the method signature — it makes the route's behaviour clear and gives your IDE accurate type information.
The Symfony container is accessible via App\Kernel::getInstance()->getContainer() from anywhere, but the framework provides global helper functions for the most common services:
em() // Doctrine EntityManager
ca() // Cache adapter (APCu or Redis)
rca() // Redis cache adapter
aca() // APCu cache adapter
mca() // Memcached cache adapter
rc() // Raw Predis client
rp() // Redis pipeline
s() // Symfony Session
qb() // Doctrine QueryBuilder
See Helper Functions for the full reference.
Calling parent::__construct() without $request — if you override the constructor and forget to pass $request to the parent, $this->request will be null and any attempt to access request data will throw a fatal error.
Returning void from a route method — the router expects a response object. A method that returns nothing causes send() to be called on null. Always return a response.
Using echo or var_dump in a controller — output before ob_start() breaks the output buffer and can cause headers-already-sent errors. Use dump() for debugging or return a JsonResponse with debug data.
Accessing $this->request in the constructor — the request object is available in the constructor, but middleware hasn't run yet at that point. Don't make access control decisions in the constructor — that's middleware's job.
Putting business logic in controllers — controllers should be thin. Route parameter extraction, calling a service or entity method, and returning a response. Heavy game logic, complex calculations, and multi-step operations belong in dedicated service classes or entity methods, not in the controller.