Controllers are PHP classes that extend AbstractController and contain route handler methods. Each method receives URL parameters, accesses the current request via r(), 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.
AbstractController includes JsonResponseHelperTrait, which provides shortcut methods for every common HTTP status code:
// 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();
// 403 Forbidden
return $this->forbidden();
// 404 Not Found
return $this->notFound();
// 406 Not Acceptable
return $this->notAcceptable();
// 422 Unprocessable Entity
return $this->unprocessableEntity( $validationErrors );
All methods accept an optional $data and optional $headers array.
For API endpoints use the api* methods instead — they wrap the response in a consistent { success, data, errors, meta } envelope:
// 200
return $this->apiSuccess( $player );
// 201
return $this->apiCreated( $newPlayer );
// 404
return $this->apiNotFound();
// 422 with validation errors
return $this->apiUnprocessableEntity( $player->getValidationErrors() );
// 204 — empty body, no envelope
return $this->apiNoContent();
See API Response Envelope for the full reference, including cursor-based pagination.
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 anywhere via r():
// GET parameters (?page=2&sort=asc)
$page = r()->query->get( 'page', 1 );
$sort = r()->query->get( 'sort', 'desc' );
// POST parameters (form submission or JSON body)
$email = r()->request->get( 'email' );
$password = r()->request->get( 'password' );
// Uploaded files
$avatar = r()->files->get( 'avatar' );
// Request headers
$token = r()->headers->get( 'X-Auth-Token' );
// Client IP
$ip = r()->getClientIp();
// HTTP method
$method = r()->getMethod();
// Check if request is XMLHttpRequest (Ajax)
$isAjax = r()->isXmlHttpRequest();
JSON request bodies are automatically merged into r()->request during the Request object construction — API endpoints that receive JSON payloads use the same r()->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()
{
$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(),
] );
}
}
AbstractController has no constructor, so there is no parent::__construct() to call. Because controllers are resolved from the Symfony DI container, the constructor is where you declare your service dependencies — they will be autowired automatically:
final class OrderController extends AbstractController
{
public function __construct(
private readonly OrderRepository $orders,
private readonly MailerInterface $mailer,
private readonly LoggerInterface $logger,
) {}
}
Access the request via r() when needed — it is not injected.
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.
For common infrastructure services, use the global helpers — they are available everywhere (controllers, middleware, views):
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.
Using $this->request in controllers — AbstractController no longer injects the request. $this->request is undefined and will throw. Use r() instead.
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.
Making access control decisions in the constructor — r() is available in the constructor, but middleware hasn't run yet at that point. Don't check authentication or authorization there — 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.