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.
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:
- The class extends the
Controllerstereotype. - A public method carries an HTTP verb attribute —
#[GetMapping],#[PostMapping]and so on. - 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.
<?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:
php call run
# listens on 0.0.0.0:8000 — overridden with --host and --port
curl http://localhost:8000/ping
# pongWhere 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:
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:
#[GetMapping] // GET / (empty path)
#[GetMapping('health')] // GET /health
#[PostMapping('users')] // POST /usersOPTIONS 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:
#[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.
#[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.
#[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:
#[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:
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/listThe 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:
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:
#[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:
#[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:
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:
#[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:
php call make -c .Post
# → main/PostController.phpInside is a working placeholder method so the application starts immediately:
<?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:
<?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 (PostRequestis a plain class with fields) and validated before the method is entered. See Requests.{id:\d+}— the numeric constraint rejects/api/posts/abcbefore the controller.- Status codes —
ResponseEntity::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:
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::deleteThe 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:
#[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:
#[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.
DEBUGmakes 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:
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 found →
404 Not Found. - Path exists, wrong verb →
405 Method Not Allowed. AnAllowheader is added listing the verbs registered for that path, so the client sees immediately what is available.
Next
- Controllers — the
Controllerstereotype and handler structure - Requests — parameter binding,
#[PathVariable], validation - CORS — the global policy and
#[CrossOrigin]on routes - Dependency injection — how
#[Autowired]fills a controller