Web basics

Routing

A route in Winter is an attribute on a controller method. There are no route files and no registration tables: you put #[GetMapping] on a method and the scanner finds it at startup and ties it to a URL. The controller code is the route map — what you see is what is served.

Attributes Kernel\Route\AnnotationDiscovery automaticInspect call mapping show

What routing is and why

Routing matches an incoming request (HTTP verb + URL path) to the code that will handle it. GET /api/posts/42 arrives — the framework has to work out which method of which class to call.

The problem. The “URL → handler” link has to be written down somewhere and kept up to date. Storing it away from the code — in a routes file or a config — creates two sources of truth: add a method to the controller, remember to add a line to routes.php. Over time the route list and the code drift apart.

The solution. Winter declares the route where the handler is — as an attribute on the method. One source of truth: the method and its URL always sit together.

Your first route

For the application to gain an endpoint, three things are needed:

  1. The class extends the Controller stereotype.
  2. A public method carries an HTTP verb attribute — #[GetMapping], #[PostMapping] and so on.
  3. The file lives inside the directories the framework scans (more on those below).

No manual registration, no line in a config: create a method with an attribute and the route exists.

main/PingController.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;

class PingController extends Controller
{
  #[GetMapping('ping')]
  public function ping(): ResponseEntity
  {
      return ResponseEntity::ok('pong');
  }
}

Start the application and check:

bash
php call run
# listens on 0.0.0.0:8000 — overridden with --host and --port

curl http://localhost:8000/ping
# pong

Where the framework looks for controllers

The walk starts at the project root and also covers the src/ directory of every attached plugin. Where exactly a file sits does not matter — a controller is found by its attributes, not by its path.

Three directories are excluded, and that is worth remembering:

Directory Why it is excluded
vendor/ Third-party code; routes from there arrive only through plugins
storage/ Generated code: the container cache, #[Async] proxies
resources/ View templates — .php files, but not application classes

A controller placed in resources/ will not be found.

Dependencies inside a controller

Controller::__construct() is declared final and takes no arguments — the constructor cannot be overridden, so constructor injection is unavailable in a controller. Dependencies go into properties with the #[Autowired] attribute:

php
class PostController extends Controller
{
  #[Autowired] private PostService $service;
}

The controller instance is built by the container, so by the time the method is called the property is already filled. Details on the Dependency injection page.

New route not showing up?

The route table is compiled once, at server startup, not per request. After a plain php call run a newly added controller needs a restart. In development use php call run dev — a watcher follows .php files and restarts the application on any change.

HTTP verbs

Every HTTP verb has its own attribute. They all take one argument — the path (empty by default) — and live in Flytachi\Winter\Kernel\Route\Annotation.

Attribute HTTP verb Purpose
#[GetMapping] GET Reading a resource
#[PostMapping] POST Creating
#[PutMapping] PUT Full update
#[PatchMapping] PATCH Partial update
#[DeleteMapping] DELETE Deleting
#[RequestMapping] On a class, a prefix. On a method, all five verbs at once: GET, POST, PUT, PATCH, DELETE

The path in the attribute is written without a leading slash — it is added for you. Add one anyway and it is discarded, so both forms work:

php
#[GetMapping]           // GET /            (empty path)
#[GetMapping('health')] // GET /health
#[PostMapping('users')] // POST /users

OPTIONS needs no registration: preflight requests are handled by the framework before route lookup — see CORS.

#[RequestMapping] on a method

The attribute has two roles. On a class it sets a prefix — covered below. On a method, where no verb is named, it attaches the handler to all five verbs for one path. Useful for webhooks and general handlers where the path matters and the verb does not:

php
#[RequestMapping('webhook')]
public function webhook(HttpRequest $request): ResponseEntity
{
  // GET, POST, PUT, PATCH and DELETE on /webhook all arrive here
  return ResponseEntity::ok();
}

Five, not “any verb”: non-standard verbs are not registered this way. Which verb actually arrived, the handler asks the request — $request->getMethod().

HEAD and OPTIONS are not in that list but are handled regardless: OPTIONS is intercepted as preflight, and HEAD is attached to the GET handler automatically.

Extra route attributes

Beyond the verb and the path, two more attributes go on a route. Both work on a class (applying to all its methods) and on a single method — the method wins.

Attribute What it sets
#[CrossOrigin] This route’s CORS policy instead of the global one — see CORS
#[Timeout] This route’s deadline in seconds instead of the global one

#[CrossOrigin] earns its keep when one endpoint needs a stricter or looser policy than the rest: the whole service is open to the main frontend, say, while the metrics summary is for the admin panel only, with an hour of preflight caching.

php
#[CrossOrigin(origins: ['https://app.example.com'], credentials: true)]
class UserController extends Controller
{
  #[GetMapping('me')]                       // the class policy
  public function me(): ResponseEntity { /* ... */ }

  #[GetMapping('stats')]
  #[CrossOrigin(origins: ['https://admin.example.com'], maxAge: 3600)]
  public function stats(): ResponseEntity { /* ... */ }   // its own policy
}

Important: the attribute replaces the global policy wholesale, it does not add to it. What each parameter means and how to set the default policy is on the CORS page.

#[Timeout] is for where the usual limit does not fit: globally a request lives 30 seconds, while a report export takes ten minutes.

php
#[Timeout(120)]                  // the whole controller: two minutes
class ReportController extends Controller
{
  #[GetMapping('export')]
  #[Timeout(600)]              // ...but ten for this export
  public function export(): ResponseEntity { /* ... */ }

  #[GetMapping('ping')]
  #[Timeout(0)]                // ...and no limit at all for this one
  public function ping(): ResponseEntity { /* ... */ }
}

When the deadline passes the client receives 504 Gateway Timeout.

What a deadline can interrupt

What gets interrupted is a request that is waiting — on the database, an external HTTP call, a file, a sleep(). finally blocks and defer still run, so transactions close and connections return to the pool. A request burning CPU in a loop with no I/O is not interrupted: until it yields, nothing at all runs in that worker, the watchdog included.

Route prefix

#[RequestMapping('prefix')] on a class sets a shared prefix for every method of the controller. The method’s path is appended with a slash:

main/OrderController.php
#[RequestMapping('api/v1/orders')]
class OrderController extends Controller
{
  #[GetMapping]                                     // GET    /api/v1/orders
  public function index(): ResponseEntity { /* ... */ }

  #[GetMapping('{id}')]                             // GET    /api/v1/orders/{id}
  public function show(#[PathVariable] int $id): ResponseEntity { /* ... */ }

  #[PostMapping]                                    // POST   /api/v1/orders
  public function create(): ResponseEntity { /* ... */ }
}

A prefix is convenient for an API version (api/v1/...) or a logical section (admin/...) without repeating the base path in every method. Change the version and you edit one line.

A method with an empty path answers on the prefix exactly — no separate “root” route is needed. Stray slashes at the edges are normalised, so #[RequestMapping('/api/v1/')] and #[GetMapping('/ping')] together give /api/v1/ping, not /api/v1//ping.

A shared base controller

Methods inherited from a base class are registered in every subclass — and take the subclass’s prefix, not the base’s. That is the intended way to give several resources the same set of endpoints:

php
abstract class CrudController extends Controller
{
  #[GetMapping('list')]
  public function list(): ResponseEntity { /* ... */ }
}

#[RequestMapping('api/posts')]
class PostController extends CrudController {}   // GET /api/posts/list

#[RequestMapping('api/users')]
class UserController extends CrudController {}   // GET /api/users/list

The base class itself contributes no routes: abstract classes, interfaces and traits are skipped by the scanner.

Subclasses must have different prefixes

A class prefix is not inherited#[RequestMapping] is read from the class itself only. If two subclasses of a shared base are left without their own prefix, both try to claim the same path and the application will not start:

RuntimeException: Ambiguous handler methods mapped for [GET] '/list'

A route conflict shows up at startup, not in production.

Plugin prefix

Controllers arriving from a plugin additionally receive its prefix — the one the plugin was attached under. Prefixes stack left to right:

text
plugin prefix  +  class prefix  +  method path
 /billing         orders          {id}
→  GET /billing/orders/{id}

So inside a plugin, paths are written without regard to the host application: keeping URLs out of each other’s way is the job of whoever attaches the plugin. More on the Packages page.

Path parameters

Dynamic segments are declared in braces — {name}. The value is bound to a method argument by the #[PathVariable] attribute and cast to the argument’s type:

php
#[GetMapping('users/{id}/posts/{slug}')]
public function post(
  #[PathVariable] int $id,       // "42" → int 42
  #[PathVariable] string $slug,  // "hello" → string
): ResponseEntity {
  // /users/42/posts/hello  →  $id = 42, $slug = "hello"
}

The link is by name, not by position: arguments can be declared in any order as long as the name matches the segment’s. The number of segments is unlimited.

If the argument name differs from the name in the path, pass it to the attribute explicitly:

php
#[GetMapping('posts/{id}')]
public function show(#[PathVariable('id')] int $postId): ResponseEntity { /* ... */ }

Argument types

A URL segment is always a string, but it reaches the method already cast to the declared type. If the cast is impossible the controller body is never reached: the client receives 400 Bad Request with a clear message.

Argument type What it accepts On a mismatch
int, float A numeric segment Path variable 'id' must be an integer, got 'abc'
bool true/false, 1/0, yes/no A message listing the accepted forms
string Any segment
A backed enum A value from the enum must be one of [draft, published], got 'x'
DateTimeImmutable A parseable date A message about the format

An enum in the path is particularly handy — the allowed-value check comes for free:

php
enum Status: string { case Draft = 'draft'; case Published = 'published'; }

#[GetMapping('posts/status/{status}')]
public function byStatus(#[PathVariable] Status $status): ResponseEntity { /* ... */ }
// /posts/status/draft   → Status::Draft
// /posts/status/xx      → 400: must be one of [draft, published], got 'xx'

An argument with #[PathVariable] is required. To make it optional, give it a default value or declare the type nullable — otherwise a missing segment produces a 400 reading Path variable 'id' is missing.

The attribute may be omitted

When the argument name matches the segment name, #[PathVariable] is optional — the resolver finds the value by name. But the code reads unambiguously with it, so the examples keep it.

Constraining with a regular expression

A segment can carry a regex after a colon — {name:pattern}. The route matches only when the segment fits the pattern; otherwise the router answers 404 without entering the controller:

php
#[GetMapping('{id:\d+}')]         // digits only:   /42      ✓   /abc     ✗
#[GetMapping('{slug:[a-z-]+}')]   // lowercase slug: /hello   ✓   /Hi      ✗
#[GetMapping('files/{path:.+}')]  // the rest of the path: /a/b/c ✓ (slashes inside)
Technique What it gives
No regex — {id} Matches any non-empty segment except /
With regex — {id:\d+} Rejects mismatches at the route level (404 before the controller)
{path:.+} Captures slashes too — this is how a “rest of path” is done
Type casting #[PathVariable] int $id converts the string to the type

What a pattern must not contain

The pattern is embedded into the route’s combined regex, and two constructs break it:

  • Braces — quantifiers like {3}, {2,4}. The closing brace ends the segment and the route silently stops matching: instead of an error you get a 404 on every request. Write {code:[A-Z][A-Z][A-Z]} rather than {code:[A-Z]{3}}.
  • Capturing groups(cat|dog). An extra group shifts parameter numbering and crashes the dispatcher on a request. Use non-capturing ones — {kind:(?:cat|dog)}.

Regex or validation?

A regex in the path is a blunt “fits / does not fit” filter with a single outcome: 404, as if the address did not exist. For a meaningful check with a clear error message, put constraints on the same argument — they fire on #[PathVariable] automatically, no #[Valid] needed:

#[PathVariable, Min(1)] int $id

Then instead of a 404 the client receives a 400 describing what is wrong. See Requests.

The CRUD template

Let’s walk a typical resource end to end — from scaffold to finished controller. This is the reference shape most API endpoints reduce to, and it is assembled from everything above: a class prefix, a constrained path parameter, verb attributes.

Scaffold

The generator writes the controller stub. It appends the Controller suffix itself, so the resource name is written short:

bash
php call make -c .Post
# → main/PostController.php

Inside is a working placeholder method so the application starts immediately:

main/PostController.php
<?php

namespace Main;

use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping;
use Flytachi\Winter\Kernel\Http\Stereotype\Controller;

class PostController extends Controller
{
  #[RequestMapping('post')]
  public function hello(): ResponseEntity
  {
      return ResponseEntity::ok("hello");
  }
}

The placeholder is meant to be replaced straight away: #[RequestMapping] on a method claims five verbs on one path, while usually one specific verb is what you want.

Where the file actually lands

By default in the application root directory (main/). But if the project already has a Controllers/, Controller/, Rests/ or Rest/ directory, the generator puts the file there and adjusts the namespace. The other flags (-s service, -r repository and the rest) are on the CLI → make page.

The controller

Let’s bring PostController to a full set of CRUD operations. Note how each method mirrors its HTTP verb and returns a fitting status:

main/PostController.php
<?php

namespace Main;

use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Http\Request\Annotation\PathVariable;
use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestJson;
use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestParam;
use Flytachi\Winter\Kernel\Http\Request\Validation\Valid;
use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
use Flytachi\Winter\Kernel\Http\Stereotype\Controller;
use Flytachi\Winter\Kernel\Route\Annotation\DeleteMapping;
use Flytachi\Winter\Kernel\Route\Annotation\GetMapping;
use Flytachi\Winter\Kernel\Route\Annotation\PostMapping;
use Flytachi\Winter\Kernel\Route\Annotation\PutMapping;
use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping;

#[RequestMapping('api/posts')]                 // shared prefix → /api/posts
class PostController extends Controller
{
  #[Autowired] private PostService $service; // dependency through the container

  #[GetMapping]                              // GET /api/posts
  public function index(#[RequestParam] int $page = 1): ResponseEntity
  {
      return ResponseEntity::ok($this->service->paginate($page));
  }

  #[GetMapping('{id:\d+}')]                   // GET /api/posts/{id}
  public function get(#[PathVariable] int $id): ResponseEntity
  {
      return ResponseEntity::ok($this->service->find($id));
  }

  #[PostMapping]                             // POST /api/posts → 201
  public function create(#[RequestJson, Valid] PostRequest $req): ResponseEntity
  {
      return ResponseEntity::created($this->service->create($req));
  }

  #[PutMapping('{id:\d+}')]                   // PUT /api/posts/{id}
  public function update(
      #[PathVariable] int $id,
      #[RequestJson, Valid] PostRequest $req,
  ): ResponseEntity {
      return ResponseEntity::ok($this->service->update($id, $req));
  }

  #[DeleteMapping('{id:\d+}')]                // DELETE /api/posts/{id} → 204
  public function delete(#[PathVariable] int $id): ResponseEntity
  {
      $this->service->delete($id);
      return ResponseEntity::noContent();
  }
}

What works alongside routing here:

  • #[Autowired] PostService — the controller does not build the service by hand; the container injects it. See Dependency injection.
  • #[RequestParam] int $page = 1 — a single query-string parameter (?page=2), optional thanks to the default value.
  • #[RequestJson, Valid] PostRequest — the body is parsed into an object (PostRequest is a plain class with fields) and validated before the method is entered. See Requests.
  • {id:\d+} — the numeric constraint rejects /api/posts/abc before the controller.
  • Status codesResponseEntity::ok() (200), created() (201), noContent() (204). The full list is on the Responses page.

Checking the result

You can confirm all five routes registered without starting the server:

bash
php call mapping show api/posts

|   GET     /api/posts Main\PostController::index
|   POST    /api/posts Main\PostController::create
|   GET     /api/posts/{id:\d+} Main\PostController::get
|   PUT     /api/posts/{id:\d+} Main\PostController::update
|   DELETE  /api/posts/{id:\d+} Main\PostController::delete

The argument is a URL fragment used as a filter; without it the command shows every route in the application.

Routing is only the entrance

The controller stays thin: take the request, delegate to a service, return a response. Business logic lives in a Service, data access in a Repository. That split is what Winter’s stereotypes are for (see Key concepts).

Several paths on one method

Verb attributes are repeatable — one handler can answer several paths at once. Handy for synonyms and backwards compatibility, when an old address must keep working with the same logic:

php
#[GetMapping('search')]
#[GetMapping('filter')]
public function search(#[RequestParam] string $q = ''): ResponseEntity
{
  // serves both GET /search and GET /filter
}

Verbs can be mixed too — the attributes on a method need not be of one kind:

php
#[GetMapping('search')]
#[PostMapping('search')]
public function search(): ResponseEntity
{
  // GET /search and POST /search — one handler
}

Every attribute produces a separate entry in the route table, and all of them follow the usual rules: they inherit the class prefix and appear as separate lines in call mapping show. The handler is one, and from its parameters it cannot tell which path invoked it — if that matters, ask the request: $request->getUri().

The verb + path pair must be unique

Two identical attributes on one method are not belt-and-braces, they are a conflict: the application fails at startup with RuntimeException: Ambiguous handler methods mapped for [GET] '/search'. Only different paths or different verbs may repeat.

Discovery and inspection

The route table is compiled once at server startup, in the master process, before the workers fork. Each worker receives it ready-made.

Two practical things follow:

  • There is no scanning per request. Reflection, walking files, reading attributes — all of that is startup cost, not request cost. Lookup in the finished table is arranged so that a path without parameters is found by a single hash-map access regardless of how many routes exist; only paths with {...} are matched by scanning.
  • Code changes are picked up only by a restart. Add a controller or change a path and the server has to restart. DEBUG makes no difference here: the table is built the same way in development and in production.

That is why in development the application is started with php call run dev — the watcher follows .php files and restarts the process itself, so a new route appears as soon as the file is saved.

Nothing to compile before deployment

No separate route-building step is needed in a deployment pipeline: the table is built when the application starts, and no route file exists.

Startup can be sped up differently: call di build prepares the project’s class list, and the route scanner uses that instead of walking the files again — see CLI → di.

Inspecting routes

To see what ended up registered — verbs, paths and handlers:

bash
php call mapping show              # every route in the application
php call mapping show api/posts    # only those whose path contains 'api/posts'

The command does not start the server, so it suits both a quick check after an edit and diagnosis: if a route is missing from the output, the controller never reached the scan (check that the file is not in resources/ and that the class is not abstract).

The same scanning pass collects more than routes along the way: #[AdviceException] handlers, plugin routes and the health actuator endpoints are all discovered in one walk of the project.

404 and 405 responses

  • Path not found404 Not Found.
  • Path exists, wrong verb405 Method Not Allowed. An Allow header is added listing the verbs registered for that path, so the client sees immediately what is available.

Next

  • Controllers — the Controller stereotype and handler structure
  • Requests — parameter binding, #[PathVariable], validation
  • CORS — the global policy and #[CrossOrigin] on routes
  • Dependency injection — how #[Autowired] fills a controller