Web basics

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.

Base Http\Stereotype\ControllerCreated by the container (DI)Returns a Sendable or plain data

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:

php
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:

bash
php call make -c .User   # → main/UserController.php

The skeleton is minimal: a class, the stereotype and one handler method.

main/UserController.php
<?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.

main/UserController.php
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:

php
#[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:

php
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():

php
// 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():

php
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 disk

By 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.

php
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:

php
#[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.

php
#[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:

main/AuthMiddleware.php
<?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:

text
AuthMiddleware::before()      ← the class first
AuditMiddleware::before()   ← then the method
  the controller method
AuditMiddleware::after()
AuthMiddleware::after()       ← and back outwards

A 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:

php
#[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