Controllers
Write controllers with
Controllers
#[AsController]
Mark any class with #[AsController] to register it as an HTTP controller:
use Vortos\Http\JsonResponse;
use Vortos\Http\Request;
use Symfony\Component\Routing\Attribute\Route;
use Vortos\Http\Attribute\AsController;
use Vortos\Auth\Attribute\RequiresAuth;
use Vortos\Auth\Identity\CurrentUserProvider;
use Vortos\Cqrs\Query\QueryBusInterface;
#[AsController]
#[Route('/users/{id}', methods: ['GET'])]
#[RequiresAuth]
final class GetUserController
{
public function __construct(
private QueryBusInterface $queryBus,
private CurrentUserProvider $currentUser,
) {}
public function __invoke(string $id, Request $request): JsonResponse
{
$user = $this->queryBus->ask(new GetUserById($id));
if ($user === null) {
return new JsonResponse(['error' => 'Not Found'], 404);
}
return new JsonResponse($user);
}
}What #[AsController] Does
#[AsController] is an autoconfiguration attribute. When detected, HttpExtension autoconfiguration:
- Sets the service as
public: true - Adds the tag
vortos.api.controller
RouteCompilerPass then scans all services tagged vortos.api.controller for #[Route] attributes and builds the RouteCollection.
Route Attribute
Vortos uses Symfony's standard #[Route] attribute:
use Symfony\Component\Routing\Attribute\Route;
// Simple GET
#[Route('/users', methods: ['GET'])]
// With path parameter
#[Route('/users/{id}', methods: ['GET'])]
// POST
#[Route('/users', methods: ['POST'])]
// Multiple methods
#[Route('/users/{id}', methods: ['PUT', 'PATCH'])]
// Named route
#[Route('/users/{id}', name: 'user.show', methods: ['GET'])]
// With requirements (regex constraint on parameter)
#[Route('/users/{id}', methods: ['GET'], requirements: ['id' => '[0-9a-f-]{36}'])]Class-Level vs Method-Level Routes
Routes can be on the class or on individual methods:
// Class-level — single-action controller (recommended for simple cases)
#[AsController]
#[Route('/users/{id}', methods: ['GET'])]
final class GetUserController
{
public function __invoke(string $id): JsonResponse { ... }
}
// Method-level — multi-action controller
#[AsController]
#[Route('/users')]
final class UserController
{
#[Route('', methods: ['GET'])]
public function index(Request $request): JsonResponse { ... }
#[Route('/{id}', methods: ['GET'])]
public function show(string $id): JsonResponse { ... }
#[Route('', methods: ['POST'])]
public function create(Request $request): JsonResponse { ... }
#[Route('/{id}', methods: ['PUT'])]
public function update(string $id, Request $request): JsonResponse { ... }
#[Route('/{id}', methods: ['DELETE'])]
public function delete(string $id): JsonResponse { ... }
}Service Injection
Inject any service in the constructor — ContainerControllerResolver resolves controllers from the container:
#[AsController]
#[Route('/documents', methods: ['POST'])]
final class CreateDocumentController
{
public function __construct(
private CommandBusInterface $commandBus,
private CurrentUserProvider $currentUser,
) {}
public function __invoke(Request $request): JsonResponse
{
$data = $request->toArray();
$identity = $this->currentUser->get();
$this->commandBus->dispatch(new CreateDocument(
title: $data['title'],
content: $data['content'],
authorId: $identity->id(),
));
return new JsonResponse(['status' => 'created'], 201);
}
}Route Parameters
Route parameters are injected as method arguments by ArgumentResolver:
#[Route('/users/{id}/documents/{docId}', methods: ['GET'])]
public function __invoke(string $id, string $docId): JsonResponse
{
// $id and $docId come from the URL
}Request Body
public function __invoke(Request $request): JsonResponse
{
// JSON body
$data = $request->toArray();
// Query string
$page = $request->query->get('page', 1);
// Headers
$token = $request->headers->get('Authorization');
}Combining Middleware Attributes
Stack security attributes on controllers freely:
#[AsController]
#[Route('/exports/bulk', methods: ['POST'])]
#[RequiresAuth]
#[RequiresPermission('exports.create.any')]
#[RequiresFeatureAccess(Feature::BulkExport)]
#[RequiresQuota(Quota::Exports, cost: 1)]
#[AuditLog(AuditAction::ExportCreated)]
final class CreateBulkExportController
{
// All middleware runs before __invoke() is called
public function __invoke(Request $request): JsonResponse { ... }
}Single-Action Controllers
Prefer single-action controllers with __invoke() over multi-action controllers. Single-action controllers are easier to test, easier to apply per-route attributes to, and follow the single responsibility principle. Multi-action controllers are fine for simple CRUD resources.