Key concepts
Seven ideas everything else follows from. Each is explained before you meet it in code: by the end of the page you should understand not only how to write Winter, but why it is written that way.
1. The application lives; it is not raised per request
This is the main difference, and nearly everything else follows from it.
How classic PHP works. A request arrives, PHP starts a process, reads configs, builds the container, serves one request and dies. Anything you put in a static property is gone. A memory leak is harmless: the process ends milliseconds later anyway.
How Winter works. The application starts once and keeps running. A master process loads the code and forks workers; each worker serves requests for months without a restart.
master loads the code once
├── worker 1 serves requests, long-lived
├── worker 2 └── coroutine ← request A
├── worker 3 └── coroutine ← request B (at the same time)
└── worker 4 └── coroutine ← request CInside a worker, requests run on coroutines, not threads. When a request waits on the database or an HTTP call, the worker does not idle — it switches to another request. One worker serves hundreds of concurrent requests while staying single-threaded: no data races, no locks needed.
Three consequences you will feel immediately:
| Startup is expensive — once | Scanning the project, building the container and reading configs all happen before the first request. The request itself raises nothing |
| State outlives the request | A static property filled during one request is visible to the next. Sometimes that is what you want; more often it is a bug |
| A slow operation does not block others | But only if it yields. Ordinary PHP functions do not |
What can now go wrong
One user’s data left in a static property will be seen by the next — not as an oddity, but as a leak between users. Because of coroutines this happens even within overlapping requests: two of them run interleaved.
So in Winter state either belongs to an object created per request, or lives in the coroutine’s context. Everything the framework keeps “current” — language, timezone, logging fields — is built exactly that way.
2. Discovery instead of registration
There is no file listing controllers, no routes.php, no service registry. At boot
the framework walks the project and finds everything itself.
start → walk every .php from the project root
(except vendor/, storage/, resources/)
→ look for a class declaration in each file
→ load what it finds and sort it out:
a controller? → routes into the table
#[Configuration]? → factories into the container
a configurer? → apply its settings
process, daemon? → register itThe walk is one for the whole application, not one for routes and another for DI. Create a class and it works. There is nothing to add to a list, and there is no such thing as “I wrote the controller but the route never appeared because I forgot to register it”.
The price is startup: the walk reads every file. So in production the result is cached and a restart no longer touches the disk. In development the cache is off — a new class has to be picked up immediately.
Hence the rule about stray .php files
The scanner cannot tell an application class from anything else — it reads a file
and loads it if it finds a class declaration. That is exactly why templates live in
resources/ and generated code in storage/: both directories are excluded from
the walk. See Project structure.
3. Attributes instead of configs
Behaviour is described next to the code it belongs to — as a PHP attribute, not as an entry in a separate file.
#[RequestMapping('users')] // prefix for the whole class
class UserController extends Controller
{
#[Autowired] private UserService $service; // dependency
#[GetMapping('{id}')] // GET /users/{id}
public function show(#[PathVariable] int $id): ResponseEntity
{
return ResponseEntity::ok($this->service->find($id));
}
}Five decisions here — prefix, dependency, method, path, parameter source — and all five are visible on one screen. In a config file they would live in four places, and renaming the method would silently pull them apart.
Attributes cover every layer of the framework:
| Layer | Attributes | Where to read |
|---|---|---|
| Routes | #[RequestMapping], #[GetMapping], #[PostMapping], #[Timeout], #[CrossOrigin] |
Routing |
| Request binding | #[PathVariable], #[RequestParam], #[RequestQuery], #[RequestBody], #[RequestJson], #[RequestForm], #[RequestFile], #[RequestHeader] |
Requests |
| Validation | #[Valid] and 24 constraints: #[NotBlank], #[Email], #[Size], #[In]… |
Validation |
| Dependencies | #[Autowired], #[Inject], #[Lazy], #[Singleton], #[Request], #[Transient] |
Dependency injection |
| Configuration | #[Configuration], #[Bean], #[Value], #[Import] |
Configuration |
| Application make-up | #[EnableWeb], #[EnableProcess], #[EnableDaemon], #[EnableScheduler], #[EnableAsync], #[EnableActuator] |
Components |
| Background and schedule | #[Async], #[Scheduled] |
Async, Scheduler |
| Errors | #[AdviceException] |
Error handling |
| Database schema | #[Table], #[Id], #[Varchar], #[ForeignKey], #[Index]… |
Database |
You do not need to memorise them: each family is covered on its own page, and the editor suggests the arguments — they are ordinary PHP classes.
4. Stereotypes
A stereotype is a base class with a defined role. By extending it you tell the framework what your class is, and you get working behaviour in return.
| Stereotype | Role | Extended by |
|---|---|---|
Controller |
Takes an HTTP request, returns a response | Controllers |
Middleware |
Runs before and after the controller | Middleware |
Repository |
Access to a table; RepositoryCrud and RepositoryView exist too |
Repository |
Process |
A unit of work outside a request: once, in background, on demand | Processes |
Daemon |
A long-lived process with workers and automatic restart | Daemons |
Cmd, CmdCustom |
A console command | Your own scripts |
A service is a plain class
There is no Service stereotype in Winter, and that is deliberate. Business logic
needs no behaviour from the framework: a plain class injects perfectly well, tests
without any environment around it, and drags no base class along.
php call make -s .Order creates exactly that — a class with no parent.
5. The container assembles objects for you
You do not write new for controllers, services or repositories. You declare what
you need; the container creates it and hands it over.
class OrderService
{
#[Autowired] private OrderRepository $orders;
#[Autowired] private PaymentGateway $payments;
}None of these classes has to be registered anywhere: the container can build any class whose constructor dependencies it can resolve.
Scope — how long an object lives
| Scope | Created | When to use |
|---|---|---|
| Transient — the default | Fresh every time | Almost always |
#[Singleton] |
Once per worker | Expensive and stateless: pools, clients |
#[Request] |
Once per request | Whatever describes this request: current user, context |
Transient by default, not singleton
If you are coming from Spring or Laravel this one is worth remembering: there a component is one per application by default, here it is a new one per injection.
The choice follows from the resident runtime. A singleton in an application that lives for weeks is an object whose state accumulates for all of that time, and that is not a price to pay by default.
The framework checks scope combinations at boot: a #[Singleton] holding a
#[Request] object means the first request’s data frozen for the worker’s whole
life. The application refuses to start and prints the chain. See
Dependency injection.
6. One application, several runtimes
Winter is not only web. What the application consists of is declared with attributes on its entry class:
#[EnableWeb] // HTTP server
#[EnableScheduler] // scheduled methods
#[EnableProcess(QueueWorker::class)] // background workers
final class Application extends WinterApplication
{
public static function main(array $argv): never
{
parent::run($argv);
}
}Drop #[EnableWeb] and you have an application with no HTTP: a scheduler and a
queue, the same code, the same services, the same container. No separate “console
kernel” and no second set of configs are required for that.
The entry file stays one — call. It starts the server and it runs the commands.
7. The request pipeline
Between the socket and your method there is a chain of steps. Each can be configured, and by default all of them already work.
request
→ route match a method by path and HTTP verb
→ middleware before(): authentication, context, logging
→ binding from URL, query, body, files — into method arguments
→ validation #[Valid] and constraints → 422 on failure
→ your method receives ready, checked, typed data
→ response ResponseEntity, ResponseView, ResponseFile
→ middleware after(): adjust the response
responseThe point of the pipeline is that already parsed and already validated data
reaches your method. Its body contains no $_GET, no json_decode, no checks for
whether a field arrived.
An exception leaves the pipeline by a different road: a handler marked
#[AdviceException] catches it and turns it into a response. That is how you give
your own error classes their own response body — see
Error handling.
Work outside a request
Four mechanisms, and choosing between them is a common question:
| Mechanism | What it is | When to reach for it |
|---|---|---|
#[Async] |
A method runs in parallel; the result is collected through a Future |
Several independent calls inside one request |
Process |
A separate unit of work; started once, in background, or on demand | An import, a report, a mailing — work with a beginning and an end |
Daemon |
A long-lived process with workers, restart and scaling | A queue, a bus consumer, a permanent handler |
#[Scheduled] |
A method invoked on a schedule or interval | Regular work: exports, cleanups, synchronisation |
Duration usually answers the question: seconds inside a request — #[Async]; work
with an end — Process; work without an end — Daemon; work driven by the clock —
#[Scheduled]. Details and examples are in
Processes.
Configuration is code
There is no separate configuration format: what needs configuring is described by a class that the same project walk discovers.
final class WebConfig extends WebConfigurerAdapter
{
public function configureCors(CorsRegistry $cors): void
{
$cors->addMapping('/api/**')->allowedOrigins('https://app.example.com');
}
public function configureServer(ServerSettings $server): void
{
$server->workers(8)->requestTimeout(30);
}
}The class needs no registration. Log channels (LoggingConfigurer) and
non-trivial objects (#[Configuration] with #[Bean] methods) are set up the same
way. Environment-dependent values live in .env — see
Configuration.
Putting it together
What happens to GET /users/1:
BEFORE STARTUP (once)
php call run
→ walk the project: controllers, configurations, configurers
→ container assembled, route table compiled
→ workers forked, the server listens
PER REQUEST (every time)
GET /users/1
→ a worker accepts it and opens a coroutine
→ route: #[GetMapping('{id}')] → UserController::show
→ middleware before()
→ the container builds UserController and injects UserService
→ #[PathVariable] binds id = 1
→ show(1) → ResponseEntity::ok(...)
→ middleware after() → response to the client
→ request objects destroyed, the worker takes the next oneNotice the boundary: everything expensive is on the left, before startup. What remains on the right is only the work that belongs to this particular request.
Next
- Installation — create a project from scratch
- Quick start — walk this path by hand
- Project structure — where things live
- Routing — the start of the web layer