Controllers are where Assegai turns an incoming HTTP request into application work.
If it helps, think of a controller the simple way: receive a request, read the useful input, call a service, and return a response.
They are responsible for:
- declaring route prefixes and handlers
- binding route, query, body, file, and host data to method parameters
- delegating work to providers
- returning JSON, views, or component-backed HTML
If you are coming from NestJS, the mental model is similar: modules group controllers, controller attributes define prefixes, method attributes define handlers, and parameter attributes bind request data.
The happy-path controller shape
Generate a resource:
assegai g r posts
You get a controller like this:
<?php
namespace Assegaiphp\BlogApi\Posts;
use Assegai\Core\Attributes\Controller;
use Assegai\Core\Attributes\Http\Body;
use Assegai\Core\Attributes\Http\Delete;
use Assegai\Core\Attributes\Http\Get;
use Assegai\Core\Attributes\Http\Post;
use Assegai\Core\Attributes\Http\Put;
use Assegai\Core\Attributes\Param;
use Assegaiphp\BlogApi\Posts\DTOs\CreatePostDTO;
use Assegaiphp\BlogApi\Posts\DTOs\UpdatePostDTO;
#[Controller('posts')]
readonly class PostsController
{
public function __construct(private PostsService $postsService)
{
}
#[Get]
public function findAll(): string
{
return $this->postsService->findAll();
}
#[Get(':id')]
public function findById(#[Param('id')] int $id): string
{
return $this->postsService->findById($id);
}
#[Post]
public function create(#[Body] CreatePostDTO $createPostDto): string
{
return $this->postsService->create($createPostDto);
}
#[Put(':id')]
public function updateById(
#[Param('id')] int $id,
#[Body] UpdatePostDTO $updatePostDto,
): string {
return $this->postsService->updateById($id, $updatePostDto);
}
#[Delete(':id')]
public function deleteById(#[Param('id')] int $id): string
{
return $this->postsService->deleteById($id);
}
}
That is the core Assegai rhythm:
- the controller owns a route prefix
- each public handler declares an HTTP verb
- request data is bound through attributes
- business logic moves quickly into a provider
Route prefixes live on the controller
The #[Controller(...)] attribute defines the local prefix for every handler on the class.
#[Controller('posts')]
class PostsController
{
#[Get]
public function findAll(): array
{
return ['posts' => []];
}
}
That maps to:
GET /posts
An empty controller path means “root of the current module branch”:
#[Controller('/')]
class HomeController
{
#[Get]
public function index(): string
{
return 'home';
}
}
Handler attributes define the HTTP method and local path
The current router recognizes these handler attributes:
#[Get]#[Post]#[Put]#[Patch]#[Delete]#[Head]#[Options]#[Sse]
Examples:
#[Get]
public function findAll(): array
{
return [];
}
#[Post]
public function create(#[Body] object $body): array
{
return ['ok' => true];
}
#[Patch(':id')]
public function update(#[Param('id')] int $id): array
{
return ['id' => $id];
}
Default response codes
The method attributes also set default status codes when the route is selected:
#[Get]defaults to200#[Post]defaults to201- the other HTTP method attributes leave the status at the normal default unless you override it
#[Sse]also sets thetext/event-streamheaders
How route paths are composed
The final route is:
module branch prefix + controller prefix + handler path
For example:
#[Controller('posts')]
class PostsController
{
#[Get(':id')]
public function findOne(#[Param('id')] int $id): array
{
return ['id' => $id];
}
}
becomes:
GET /posts/:id
If the controller lives inside an imported ApiModule branch with #[Controller('api')], the same handler becomes:
GET /api/posts/:id
Route path patterns
Assegai supports several useful path shapes.
Static routes
#[Get('me')]
public function me(): array
{
return ['id' => 'me'];
}
Dynamic route params
#[Get(':id')]
public function findOne(#[Param('id')] int $id): array
{
return ['id' => $id];
}
Constrained route params
Constrained params are one of the strongest parts of the current router.
#[Get(':id<int>')]
public function findById(#[Param('id')] int $id): array
{
return ['id' => $id];
}
#[Get(':slug<slug>')]
public function findBySlug(#[Param('slug')] string $slug): array
{
return ['slug' => $slug];
}
The built-in constraints currently verified by the unit suite are:
intsluguuidalphaalnumhexulid
Use constraints when:
- a route param has an obvious shape
- static and dynamic routes would otherwise be ambiguous
- you want the route itself to document the contract
Wildcard routes
The router also supports * wildcards in controller prefixes and handler paths.
#[Controller('files')]
class FilesController
{
#[Get('*')]
public function catchAll(): string
{
return 'wildcard';
}
}
Current matching behavior favors exact routes over wildcard routes when both could match the same request. That means you can keep a catch-all without breaking a more specific route at the branch root.
Parameter binding
The handler signature is where Assegai starts to feel productive. You can bind request data directly into typed parameters.
The most useful binding attributes are:
#[Param('id')]#[Query('search')]#[Body]#[Req]#[Res]#[UploadedFile]#[HostParam('account')]
Additional request-context attributes also exist:
#[Session]#[Ip]
#[Ip] is deprecated in the source and should not be the default choice for new code.
Route params with #[Param]
#[Get(':id<int>')]
public function findOne(#[Param('id')] int $id): array
{
return ['id' => $id];
}
If you omit the key, Assegai binds the whole route-param collection:
#[Get(':id')]
public function debug(#[Param] object $params): object
{
return $params;
}
Automatic scalar param fallback
The router can also fall back to plain scalar arguments when the parameter name matches a captured route param:
#[Get(':id')]
public function findOne(int $id): string
{
return "post-$id";
}
This is handy for small handlers, but #[Param('id')] is still the clearest choice when you want the binding to be explicit.
Query strings with #[Query]
Bind the whole query object:
<?php
use Assegai\Core\Attributes\Http\Query;
use Assegai\Core\Http\Requests\RequestQuery;
#[Get]
public function index(#[Query] RequestQuery $query): array
{
return [
'search' => $query->get('search'),
'limit' => $query->get('limit', '10'),
];
}
Or bind one key:
#[Get]
public function index(#[Query('search')] ?string $search = null): array
{
return ['search' => $search];
}
Request bodies with #[Body]
#[Post]
public function create(#[Body] CreatePostDTO $dto): string
{
return $this->postsService->create($dto);
}
This works well with generated DTOs because they are already shaped for Assegai's DI and validation flow.
Bind a single body field when you need a smaller shape:
#[Post]
public function rename(#[Body('name')] string $name): array
{
return ['name' => $name];
}
Form posts work too
Request now handles:
application/jsonapplication/x-www-form-urlencodedmultipart/form-data
That means a controller can accept form submissions through the same #[Body] flow:
#[Post]
public function submit(#[Body] object $body): object
{
return $body;
}
or pair body data with uploaded files.
Uploaded files with #[UploadedFile]
<?php
use Assegai\Core\Attributes\UploadedFile;
#[Post('avatar')]
public function upload(#[UploadedFile] object $file): array
{
return ['name' => $file->avatar['name'] ?? null];
}
Under the hood this is driven by Request::getFile(), which is populated during multipart form handling.
Access to the raw request and response
You do not always need the lower-level objects, but they are available:
<?php
use Assegai\Core\Attributes\Req;
use Assegai\Core\Attributes\Res;
use Assegai\Core\Http\Requests\Request;
use Assegai\Core\Http\Responses\Response;
#[Get('meta')]
public function meta(#[Req] Request $request, #[Res] Response $response): array
{
$response->setStatus(202);
return [
'method' => $request->getMethod()->value,
'path' => $request->getPath(),
'host' => $request->getHostName(),
];
}
Reach for #[Res] when you need to manipulate the response object directly. For most handlers, returning a value is still the cleanest approach.
Host and subdomain routing
Assegai controllers now support host-based routing in the #[Controller] attribute.
Exact host match
#[Controller(path: 'dashboard', host: 'admin.example.com')]
class AdminDashboardController
{
#[Get]
public function index(): string
{
return 'admin-dashboard';
}
}
This handler only activates for requests to:
admin.example.com/dashboard
Multiple hosts
#[Controller(path: 'reports', host: ['ops.example.com', 'support.example.com'])]
class ReportsController
{
#[Get]
public function index(): string
{
return 'reports';
}
}
Dynamic subdomains with #[HostParam]
<?php
use Assegai\Core\Attributes\Controller;
use Assegai\Core\Attributes\HostParam;
use Assegai\Core\Attributes\Http\Get;
#[Controller(path: 'dashboard', host: ':account.example.com')]
class TenantDashboardController
{
#[Get]
public function index(#[HostParam('account')] string $account): string
{
return "tenant-$account";
}
}
For a request to acme.example.com/dashboard, the handler receives:
$account === 'acme'
Matching behavior
When multiple controllers share the same path, the router now prefers:
- the most specific path
- the most specific host match
- the longest route when specificity ties
In practice that means:
- an exact host like
admin.example.combeats:account.example.com - a host-constrained controller beats a host-agnostic controller when both match the path
- a generic controller still serves as a fallback when no host pattern matches
Proxy-aware host resolution
Request host matching is normalized from the incoming request metadata. The current Request implementation prefers:
X-Forwarded-HostHostSERVER_NAMEREMOTE_HOST
Ports are stripped and hostnames are normalized to lowercase before matching.
Shaping the response
Controllers can return several kinds of results.
Arrays and objects become JSON
#[Get]
public function index(): array
{
return ['ok' => true];
}
This is the default API-style path.
Return a Response when you want manual control
<?php
use Assegai\Core\Attributes\Res;
use Assegai\Core\Http\Responses\Response;
#[Get]
public function ping(#[Res] Response $response): Response
{
return $response->plainText('pong');
}
The Response object gives you helpers like:
json(...)html(...)plainText(...)setStatus(...)
Return a classic View for server-rendered templates
<?php
use Assegai\Core\Rendering\View;
#[Get]
public function home(): View
{
return view('index', ['title' => 'Hello']);
}
Return a component for component-backed HTML
<?php
use Assegai\Core\Components\Interfaces\ComponentInterface;
#[Get]
public function about(): ComponentInterface
{
return render(AboutComponent::class);
}
For a fuller walkthrough of server-rendered UI, HTMX, and Web Components, see Pages and Components.
Status-code and header overrides
Override the response code
Use #[HttpCode(...)] or #[ResponseStatus(...)] when the method default is not what you want.
GET,PUT,PATCH,DELETE,HEAD, andOPTIONSdefault to200POSTdefaults to201- explicit status attributes override those defaults regardless of attribute order
- manual changes made through
#[Res] Response $responsestill win at runtime
<?php
use Assegai\Core\Attributes\Http\HttpCode;
use Assegai\Core\Attributes\ResponseStatus;
#[Post]
#[HttpCode(202)]
public function queueJob(): array
{
return ['queued' => true];
}
#[Get('health')]
#[ResponseStatus(204)]
public function health(): array
{
return [];
}
Set response headers
<?php
use Assegai\Core\Attributes\Http\Header;
#[Get('download')]
#[Header('X-Export-Version', '1')]
public function download(): array
{
return ['ok' => true];
}
#[Header] is method-level and is applied only when the route is selected. You can repeat it to queue multiple headers.
Redirect from a handler
Use #[Redirect(...)] when the handler should resolve as an HTTP redirect.
<?php
use Assegai\Core\Attributes\Http\Get;
use Assegai\Core\Attributes\Http\Redirect;
#[Get('login')]
#[Redirect('/sign-in', 302)]
public function login(): string
{
return 'Redirecting...';
}
If you need to decide dynamically, inject the response and redirect manually:
<?php
use Assegai\Core\Attributes\Http\Get;
use Assegai\Core\Attributes\Res;
use Assegai\Core\Http\Responses\Response;
#[Get('logout')]
public function logout(#[Res] Response $response): Response
{
return $response->redirect('/goodbye', 303);
}
Route-level redirects and headers are applied before the handler runs, which means handler code can still override them when needed.
Route trees are shaped by modules
Assegai routing is not defined in a second, separate router file. The route tree follows module composition.
If your root module imports UsersModule, PostsModule, and AboutModule, each feature brings its own controllers and prefixes with it:
UsersControllerwith#[Controller('users')]lives at/usersPostsControllerwith#[Controller('posts')]lives at/postsAboutControllerwith#[Controller('about')]lives at/about
That keeps route structure aligned with code structure.
The CLI can build nested controller branches
For example:
assegai g r api
assegai g r api/posts
This gives you nested modules and controllers, and the CLI updates the module graph for you:
AppModuleimportsApiModuleApiModuleimportsPostsModule
With controller prefixes like api and posts, the resulting branch looks like:
GET /apiGET /api/:idGET /api/postsGET /api/posts/:id
That pattern scales well when you want a dedicated API area without abandoning the feature-module structure.
Guards and interceptors still sit naturally on controllers
Controllers can also participate in the cross-cutting pipeline.
Guard example
<?php
use Assegai\Core\Attributes\UseGuards;
use Assegai\Core\Interfaces\ICanActivate;
use Assegai\Core\Interfaces\IExecutionContext;
class AdminGuard implements ICanActivate
{
public function canActivate(IExecutionContext $context): bool
{
return true;
}
}
#[UseGuards(AdminGuard::class)]
#[Get('admin')]
public function adminOnly(): array
{
return ['ok' => true];
}
Interceptor example
<?php
use Assegai\Core\Attributes\UseInterceptors;
use Assegai\Core\Interceptors\EmptyResultInterceptor;
#[UseInterceptors(EmptyResultInterceptor::class)]
#[Get(':id')]
public function maybeFindOne(#[Param('id')] int $id): array
{
return [];
}
For the deeper pipeline story, see Guards, Interceptors, Pipes, and Middleware.
Practical advice
A good default controller style in Assegai looks like this:
- use one controller prefix per feature
- keep handlers thin and push real work into providers
- prefer constrained route params over manual parsing
- use explicit binding attributes when the signature would otherwise be ambiguous
- return plain arrays, objects, views, or components and let responders do their job
- use host-based routing when subdomains express real product boundaries, not just because they can
Notes on current behavior
This guide prefers verified behavior over wishful API descriptions.
A few accuracy notes are worth keeping in mind:
- the current router recognizes the method decorators listed earlier, especially
Get,Post,Put,Patch,Delete,Head,Options, andSse - constrained route params, wildcard precedence, nested module routing, and host-based controller matching are all covered by the current unit suite
- form submissions now flow through the same request-binding story as JSON bodies
If a controller pattern is important to you and not described here, it is usually a good sign to check the unit tests or add one before relying on it in production.