Controllers
A controller takes an HTTP request and returns a response. In Winter it is a class
extending the Controller stereotype; methods carrying route attributes are the
handlers. The class itself is deliberately “empty”: dependencies arrive through the
container and all behaviour is declared with attributes.
What a controller is and why
A controller is the boundary between HTTP and your application: the point where a request arrives and a response leaves.
The problem. Application logic must not depend on HTTP directly: when request parsing, business logic and response building are mixed in one place, the code is hard to test and impossible to reuse — the same operation cannot be called from a console command or a background job.
The solution is a thin handler layer: the controller takes the request, delegates the work to a service and returns a response, without mixing transport with logic.
In Winter Controller is a stereotype: a base class with the clear role of “HTTP
entry point”. It is deliberately minimal — essentially a marker by which the scanner
finds handlers and the container knows how to build an instance:
namespace Flytachi\Winter\Kernel\Http\Stereotype;
abstract class Controller implements ControllerInterface
{
final public function __construct() {}
}One detail of that class shapes nearly everything about working with it: the
constructor is final and takes no arguments. Two consequences follow:
- The constructor cannot be overridden. There is no constructor injection in a
controller — dependencies go into properties through
#[Autowired]. - You never write
new UserController(). The framework creates the instance per request, resolving dependencies along the way.
Creating one
The generator writes the stub — it appends the Controller suffix itself:
php call make -c .User # → main/UserController.phpThe skeleton is minimal: a class, the stereotype and one handler method.
<?php
namespace Main;
use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
use Flytachi\Winter\Kernel\Http\Stereotype\Controller;
use Flytachi\Winter\Kernel\Route\Annotation\GetMapping;
use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping;
#[RequestMapping('users')]
class UserController extends Controller
{
#[GetMapping]
public function index(): ResponseEntity
{
return ResponseEntity::ok([]);
}
}For the framework to pick the class up as a controller, three things must hold:
| Condition | Why |
|---|---|
Extends Controller |
That is how the scanner tells handlers from other classes |
| Not abstract | Abstract classes, interfaces and traits are skipped |
| Lives in a scanned directory | resources/, storage/ and vendor/ are excluded |
Handlers are public methods carrying route attributes. How those attributes work — verbs, prefixes, path parameters — is on the Routing page; from here on this page is about the class itself.
Dependencies
A controller nearly always needs services and repositories. You do not create them
with new — the container injects them. The constructor is unavailable for that, so
there is one way: a typed property with the #[Autowired] attribute.
use Flytachi\Winter\DI\Attribute\Autowired;
#[RequestMapping('users')]
class UserController extends Controller
{
#[Autowired] private UserService $service;
#[Autowired] private LoggerInterface $logger;
#[GetMapping('{id}')]
public function get(#[PathVariable] int $id): ResponseEntity
{
$this->logger->info('fetch user', ['id' => $id]);
return ResponseEntity::ok($this->service->find($id));
}
}The property may be private — the container fills it before control reaches your
method. The property type is what gets injected.
The logger, by type
#[Autowired] LoggerInterface $logger gives you a logger automatically named
after the consuming class — there is no need to write getLogger(self::class),
the container supplies the name. The mechanics are in the
logger docs.
Manual bindings, #[Lazy] and container setup in detail are on the
Dependency injection page.
Controller lifetime
A controller is created afresh for every request. The instance is neither reused nor shared between requests, so it is safe to fill its properties with the current request’s data — a neighbouring request will not see them.
That is the default: a class with no scope attribute is transient, built anew on
every resolve.
Scopes
The behaviour is changed by a class attribute. Controllers rarely need it, but the table is worth knowing — the same attributes go on your services:
| Attribute | How many instances | When it fits |
|---|---|---|
| none (default) | A new one per injection | Anything holding state |
#[Singleton] |
One per worker process | Stateless classes: factories, pools, clients |
#[Request] |
One per request (per coroutine under Swoole) | Request context: authorisation, unit of work |
#[Transient] |
A new one per injection — stated explicitly | When you want to say so in code |
Do not put `#[Singleton]` on a controller
With that attribute the instance becomes shared across every request in the worker and every coroutine inside it. A property in which a handler stored the current user’s data instantly becomes a race: a neighbouring request reads someone else’s. An ordinary controller needs no scope at all — the default is already right.
Note the other side too: a service without #[Singleton] is also rebuilt on every
request. If it opens a connection or holds a warmed cache, that costs — mark it
explicitly.
Scope conflicts are caught at startup
The combination “a #[Singleton] holding a #[Request] dependency” is a hidden
bug: the first request’s object would be frozen inside the singleton for the
worker’s whole life. Winter walks the dependency graph at load time and fails with
ScopeConflictException, showing the chain A → $prop: B, rather than letting the
application start with it.
Returning a response
Whatever the method returns is what the framework sends. There are two ways: return a ready response object, or return plain data.
Plain data
Any non-null value is wrapped into a 200 response automatically. For quick
endpoints that is enough:
#[GetMapping('ping')]
public function ping(): string
{
return 'pong'; // 200, text/plain
}
#[GetMapping]
public function index(): array
{
return $this->service->all(); // 200, application/json
}The return type decides the Content-Type:
| What you returned | Content-Type |
|---|---|
| An array or an object | Negotiated with Accept; application/json by default |
A string, a number, a bool |
Always text/plain; charset=utf-8 |
null (or void) |
No response is sent — see the warning below |
An object with a toArray() method is passed through it before serialisation —
entities and DTOs go out as they are, with no manual unwrapping.
`null` is not an empty response
A handler returning null, or declared void, sends nothing: the framework has
nothing to serialise and the client is left with no answer. If you genuinely need no
body, return ResponseEntity::noContent() — a proper 204.
A ready response object
When you need a specific status, headers or a particular format, return an object
implementing Sendable. There are four:
| Class | What for |
|---|---|
ResponseEntity |
Data and status codes — the main type for APIs |
ResponseView |
Server-rendered HTML from templates |
ResponseFile |
Files and exports assembled in memory |
ResponseStreamFile |
Streaming a file from disk without loading it into memory |
ResponseEntity — data and codes
The factory sets the HTTP status; the argument becomes the body:
return ResponseEntity::ok($data); // 200
return ResponseEntity::created($data); // 201
return ResponseEntity::accepted($data); // 202
return ResponseEntity::noContent(); // 204
return ResponseEntity::badRequest($err); // 400
return ResponseEntity::notFound(); // 404
return ResponseEntity::status(HttpCode::IM_A_TEAPOT)->body($data); // any status
// headers, builder-style — calls chain:
return ResponseEntity::ok($data)->header('X-Total-Count', (string) $total);There are also unauthorized(), forbidden(), conflict(), unprocessable() and
internalError() — the full list is on the Responses page.
Throwing an error beats returning one
Returning ResponseEntity::notFound() from deep inside a method means dragging the
check through every branch. Throw an exception instead: the framework turns it into
a response with the right status. See Error handling.
ResponseView — HTML
For server-side template rendering. view() renders the resource on its own;
render() wraps it in a layout — inside the layout the finished HTML is printed by
wrContent():
// a resource inside a layout: resources/views/layouts/main.php + resources/views/users/index.php
return ResponseView::render('layouts/main', 'users/index', ['users' => $users]);
// the resource only, no layout — an htmx fragment, say:
return ResponseView::view('users/row', ['user' => $user]);The third argument is the data, available in the template as variables. More on the Views page.
ResponseFile — files and exports
Assembles a file in memory and sends it. The file name is required in every factory
except file():
return ResponseFile::csv($rows, 'report.csv'); // a CSV export
return ResponseFile::json($payload, 'export.json'); // a JSON file
return ResponseFile::xml($data, 'feed.xml'); // XML
return ResponseFile::txt($text, 'notes.txt'); // text
return ResponseFile::binary($bytes, 'image.png'); // arbitrary bytes
return ResponseFile::file('/abs/path/report.pdf'); // a file from diskBy default csv and binary go out as attachments (a save dialog), while json,
xml, txt and file are shown inline. The $isAttachment argument switches
that.
ResponseStreamFile — large files
For video, archives and anything that should not be lifted into memory whole. The
file is streamed, and the response behaves like a proper file server: it supports
HTTP Range (resuming and seeking), conditional requests and the ETag /
Last-Modified validators.
return ResponseStreamFile::open('/abs/path/video.mp4');
// download instead of viewing, cached for an hour:
return ResponseStreamFile::open($path)->attachment()->maxAge(3600);
// send it whole, refusing partial requests:
return ResponseStreamFile::open($path)->acceptRanges(false);The difference from ResponseFile::file() is memory: that one reads the file in
full, this one hands it over directly, so the file size does not run into the
worker’s memory limit.
The thin controller
A controller is an entry point, not a place for business logic. Keep it thin: take
the request, delegate to a service, return a response. Logic belongs in a Service,
data access in a Repository:
#[PostMapping]
public function create(#[RequestJson, Valid] CreateUserRequest $req): ResponseEntity
{
// no logic here — orchestration only
$user = $this->service->register($req);
return ResponseEntity::created($user);
}Notice what is absent from that method: parsing the request body, checking fields, building JSON by hand. The body is already parsed into an object and validated before the method is entered, and the response is assembled from the returned value. One meaningful call is all that is left for the controller.
Why this way
Thin controllers are easier to test and to reuse: the same service logic can be
called from a controller, a console command or a background job. The
Controller → Service → Repository split is Winter’s basic model (see
Key concepts).
Middleware on a controller
Anything that has to happen before the handler or after it — checking authorisation, timing the request, enriching the response — moves into middleware. It is attached to a controller with an attribute: on the class (then it applies to every method) or on a single method.
#[AuthMiddleware] // for the whole controller
#[RequestMapping('admin')]
class AdminController extends Controller
{
#[AuditMiddleware] // ...and additionally for this method only
#[GetMapping('stats')]
public function stats(): ResponseEntity { /* ... */ }
}A middleware class is also an attribute
For a class to be usable as an attribute it must not only extend the Middleware
stereotype but be declared an attribute itself. Without the #[Attribute] line
PHP refuses to apply it:
<?php
namespace Main;
use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;
use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse;
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
class AuthMiddleware extends Middleware
{
public function before(HttpRequest $request, HttpResponse $response): void
{
// the check that runs before the handler
}
}The generator does this for you — php call make -m .Auth creates a stub with that
line already in place.
Execution order
Class middleware runs before method middleware. The before() methods go in
declaration order, the after() methods in reverse, like nested brackets:
AuthMiddleware::before() ← the class first
AuditMiddleware::before() ← then the method
the controller method
AuditMiddleware::after()
AuthMiddleware::after() ← and back outwardsA request can be aborted from before() — throw an exception: the handler is never
reached and the exception becomes an HTTP response the usual way.
Middleware with parameters
Arguments passed to the attribute reach the middleware constructor. That lets one class serve different rules without duplication:
#[RoleMiddleware('admin')]
#[GetMapping('stats')]
public function stats(): ResponseEntity { /* ... */ }The middleware itself is built by the container, so #[Autowired] works inside it
too — dependencies are injected exactly as in a controller.
How to write before() / after() in detail, what after() may return, and which
middleware ship with the framework are on the
Middleware page.
Next
- Routing — verb attributes, prefixes, path parameters
- Requests — binding incoming data
- Views — templates, layouts and data for
ResponseView - Error handling — exceptions instead of status codes
- Dependency injection — how
#[Autowired]gets filled - Middleware — pre- and post-processing