Controllers are PHP classes in src/Controller/ that handle HTTP requests. Each public method with a #[Route] attribute becomes a route handler.
Extend AbstractController. No registration is required — the route scanner picks up any class in src/Controller/ automatically.
// src/Controller/AboutController.php
namespace App\Controller;
use Modufolio\Appkit\Core\AbstractController;
use Modufolio\Psr7\Http\Response;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Routing\Attribute\Route;
class AboutController extends AbstractController
{
#[Route(path: '/about', name: 'about', methods: ['GET'])]
public function index(): ResponseInterface
{
return Response::html('<h1>About</h1>');
}
}A controller may return an Inertia page (Inertia::render($component, $props)) or any Http\ResponsableInterface; the kernel turns it into the
response with the request it is handling. See Inertia.
AbstractController receives framework services via setSubscribedServices(), called automatically by the Kernel on instantiation. These protected properties are available in every controller method:
| Property | Type | Description |
|---|---|---|
$entityManager |
EntityManagerInterface |
Doctrine entity manager |
$flashBag |
FlashBagInterface |
Session flash messages |
$tokenStorage |
TokenStorageInterface |
Current authentication token |
$urlGenerator |
UrlGeneratorInterface |
Route URL generator |
$userProvider |
UserProviderInterface |
Load users by identifier |
$validator |
ValidatorInterface |
Symfony validator |
$inertia |
InertiaRendererInterface |
flash() before a redirect; fails with what to wire when the host has no Inertia |
One method is available for getting the current user:
$user = $this->getUser(); // returns UserInterface|nullAsk for the template as a parameter. #[Template] names it, and the TemplateResolver in your parameter pipeline supplies the view and layout paths and the current request, so the controller only renders:
use Modufolio\Appkit\Attributes\Template;
use Modufolio\Appkit\Template\Template as TemplateEngine;
use Modufolio\Psr7\Http\Response;
#[Route(path: '/', name: 'home', methods: ['GET'])]
public function index(#[Template('home')] TemplateEngine $template): ResponseInterface
{
return Response::html($template->render([
'title' => 'Home',
'user' => $this->getUser(),
]));
}#[Template('home', layout: 'admin')] also selects the layout. The resolver is registered once, in the App's parameter pipeline, with the paths it should use — see #[Template] below. Without it, construct the template yourself:
use Modufolio\Appkit\Template\Template;
use Psr\Http\Message\ServerRequestInterface;
public function index(ServerRequestInterface $request): ResponseInterface
{
$template = new Template(
name: 'home',
templatePaths: [dirname(__DIR__, 2) . '/resources/views'],
layoutPaths: [dirname(__DIR__, 2) . '/resources/views/layouts'],
request: $request,
);
return Response::html($template->render(['title' => 'Home']));
}See Templates for the full template API.
Typehint ServerRequestInterface in your method signature. It is injected automatically.
#[Route(path: '/search', name: 'search', methods: ['GET'])]
public function search(ServerRequestInterface $request): ResponseInterface
{
$query = $request->getQueryParams()['q'] ?? '';
// ...
}Modufolio\Psr7\Http\Response provides static factory methods for the most common response types. A controller method returns a ResponseInterface, or one of the two things the kernel finishes into one — an Inertia page or a Http\ResponsableInterface (see above). Anything else is a LogicException.
use Modufolio\Psr7\Http\Response;
// Redirect
return Response::redirect($this->urlGenerator->generate('home'));
return Response::redirect('/login', 302);
// JSON — json(string|array $body, ?int $code = null, ?bool $pretty = null, array $headers = [])
return Response::json(['status' => 'ok', 'id' => $entity->getId()]);
return Response::json($errors, 422);
return Response::json($data, 200, true, ['X-Total' => '42']); // pretty-print + headers
// HTML
return Response::html('<h1>Hello</h1>');
return Response::html($template->render($data), 200);
// Empty (204 No Content)
return Response::empty();
// Error shortcuts
return Response::unauthorized('Login required');
return Response::unavailable('Down for maintenance');
return Response::tooManyRequests('Slow down');All redirect status codes are validated. Allowed values: 301, 302, 303, 307, 308.
For full control, construct a response directly:
return new Response(
status: 200,
headers: ['Content-Type' => 'text/csv'],
body: $csvContent,
);AppKit's parameter resolver reads PHP attributes on method parameters and injects values automatically. The design is inspired by php-di/invoker and Symfony's argument resolver system.
Injects the authenticated user directly into the method. Returns null if no user is authenticated.
use Modufolio\Appkit\Attributes\CurrentUser;
public function dashboard(#[CurrentUser] UserInterface $user): ResponseInterfaceLoads a Doctrine entity from the database using route parameters as criteria. Throws a 404 if the entity is not found and the parameter is non-nullable. Returns null if the parameter is nullable (?Post).
use Modufolio\Appkit\Attributes\MapEntity;
#[Route(path: '/posts/{id}', name: 'post.show', methods: ['GET'])]
public function show(#[MapEntity] Post $post): ResponseInterfaceMap a route parameter to an entity field with mapping (route param name => field name):
#[Route(path: '/posts/{slug}', name: 'post.show', methods: ['GET'])]
public function show(#[MapEntity(mapping: ['slug' => 'slug'])] Post $post): ResponseInterfaceAdd fixed criteria, exclude keys, or strip nulls:
#[MapEntity(criteria: ['status' => 'published'], stripNull: true)] Post $postDeserialises and validates the request body into a typed object.
use Modufolio\Appkit\Attributes\MapRequestPayload;
#[Route(path: '/api/posts', name: 'api.posts.create', methods: ['POST'])]
public function create(#[MapRequestPayload] CreatePostDto $dto): ResponseInterfaceBy default, validation failures throw a 422 exception. Set throwOnError: false to receive a ValidationResult instead. The ValidationResult parameter may sit anywhere in the signature; with several mapped payloads, the first ValidationResult reports on the first payload, the second on the second, and so on:
public function create(
#[MapRequestPayload(throwOnError: false)] CreatePostDto $dto,
ValidationResult $result
): ResponseInterface {
if ($result->hasErrors()) {
return Response::json(['errors' => $result->errors()], 422);
}
// ...
}See Forms for DTO definition and validation constraints.
Same as #[MapRequestPayload] but reads from the URL query string.
public function search(#[MapQueryString] SearchQuery $query): ResponseInterfaceBinds a single query parameter to a primitive argument (int, float, bool, string, array, a BackedEnum, or a Uuid/Ulid), coercing the value with filter_var(). The argument name is used as the parameter name unless you pass name.
#[Route(path: '/posts', name: 'posts.index', methods: ['GET'])]
public function index(
#[MapQueryParameter(name: 'q')] ?string $search = null,
#[MapQueryParameter] int $page = 1,
#[MapQueryParameter] SortDirection $sort = SortDirection::Desc,
): ResponseInterfaceA missing parameter falls back to the argument default, or null if the argument is nullable; otherwise a 400 is thrown. An invalid value throws a 400 unless FILTER_NULL_ON_FAILURE is set in flags. Use filter, flags, and options for finer control (e.g. #[MapQueryParameter(options: ['min_range' => 1])]).
Use this for individual scalars; reach for #[MapQueryString] or #[MapFilter] when you want the whole query string mapped to an object.
Resolves the parameter into a Modufolio\Appkit\Template\Template named by the attribute, with the paths and request already set; layout: selects the layout. See Rendering a template for the controller side.
use Modufolio\Appkit\Resolver\TemplateResolver;
// In App::parameterResolver(), beside the other request-bound resolvers
new AttributeParameterResolver([
// ...
new TemplateResolver(
[$this->baseDir . '/resources/views'],
[$this->baseDir . '/resources/views/layouts'],
$this->request(),
),
]),The resolver holds the request, so it belongs in the pipeline the App rebuilds on reset(), exactly like MapQueryParameterResolver and MapRequestPayloadResolver.
Builds a filter object from query parameters. Your filter class must implement MapFilterInterface, which requires a static fromArray(array $data): self. The resolver checks the interface with assert(), so a class that only has a fromArray() fails in development (zend.assertions=1) and passes unnoticed where assertions are compiled out (zend.assertions=-1, the production default).
The whole query array is passed to fromArray() — parameters are flat (?search=x), not namespaced under filter[...].
public function list(#[MapFilter] PostFilter $filter): ResponseInterfaceIf your controller needs services beyond what AbstractController provides, declare them as constructor arguments and wire them in config/controllers.php. An application that uses the Symfony container behind the kernel can let it autowire the controller instead — load() over src/Controller/ — and an entry in controllers.php still wins for any controller it names.
class PostController extends AbstractController
{
public function __construct(
private readonly MailerInterface $mailer,
private readonly CsrfTokenManagerInterface $csrf,
) {}
}// config/controllers.php
return [
PostController::class => [
MailerInterface::class,
CsrfTokenManagerInterface::class,
],
];In a plain list the array order must match the constructor parameter order. Keying the entries by parameter name ('mailer' => MailerInterface::class) passes them as named arguments instead — preferred for more than two dependencies, because named keys cannot be transposed. See Dependency injection.