Web-layer configuration
The web layer is the part of the application that accepts HTTP requests and returns responses: the server, routing, controllers. It has decisions that belong to no single controller and no single route, because they concern the whole application at once.
There are two kinds of such decisions:
- How the server itself is built — which address it listens on, how many processes it keeps, how much memory a request may use, how long it waits for a slow handler.
- Who the browser will let call it — the cross-origin policy, CORS.
The first is settled once at startup, the second on every request.
The problem. Settings like these spread across a project easily: the port is on the command line, the worker count in a systemd unit, the memory limit in an environment variable, CORS somewhere in bootstrap code. Six months later, answering “where did this value come from” takes longer than the change itself. And none of it is checked: a typo in a parameter name stays a typo right up to startup.
The solution. Winter gathers these decisions into one class — the web-layer configurer. It is written in ordinary code, so types and editor hints check it; it lives in the project alongside everything else; and it attaches itself — there is no registration point to find.
The configurer class
Extend WebConfigurerAdapter and override the methods you need:
<?php
namespace Main;
use Flytachi\Winter\Kernel\App\ApplicationArguments;
use Flytachi\Winter\Kernel\App\Config\CorsRegistry;
use Flytachi\Winter\Kernel\App\Config\ServerSettings;
use Flytachi\Winter\Kernel\App\Config\WebConfigurerAdapter;
final class WebConfig extends WebConfigurerAdapter
{
public function configureServer(ServerSettings $server, ApplicationArguments $args): void
{
$server->port($args->int('port', 8000))
->workers(4)
->requestTimeout(30);
}
public function configureCors(CorsRegistry $cors): void
{
$cors->allowedOrigins('https://app.example.com')
->allowedHeaders('Content-Type', 'Authorization')
->allowCredentials();
}
}The class needs no registration. The same scan that finds controllers finds it,
so its name and location are arbitrary as long as the file sits in a scanned
directory (not resources/, storage/ or vendor/).
What to extend
There are two options, and the difference is whether the language forces you to implement both methods:
| You extend | What you get | When to choose it |
|---|---|---|
WebConfigurerAdapter |
Both methods already implemented as empty — override only what you need | The usual case |
WebConfigurer (the interface) |
Nothing implemented — PHP demands both methods | When you want the second one to be impossible to forget |
WebConfigurerAdapter is the same interface with empty stubs, so the choice affects
nothing but convenience.
The classes you work with
Three classes appear in the two method signatures. You do not need to know how they are built — each solves exactly one job:
| Class | Where it comes from | What it is |
|---|---|---|
ServerSettings |
The first argument of configureServer() |
Server settings |
ApplicationArguments |
The second argument of configureServer() |
The parsed command line |
CorsRegistry |
The argument of configureCors() |
The CORS policy |
ServerSettings is the set of parameters for the server being started: address,
process count, memory and time limits. It is a fluent builder: every method returns
the object, so calls chain. The object arrives already filled — with defaults and
whatever was found in environment variables — so you adjust what you need rather
than describing everything anew.
ApplicationArguments is what the application was started with:
php call run --port=9000 becomes an object you can ask for a value by name. It
exists so that you choose where a setting comes from: a flag, .env or a number
written in code — the decision stays yours rather than the framework’s.
CorsRegistry accumulates the cross-origin policy. Also a fluent builder, but
unlike ServerSettings it arrives empty: until one of its methods is called, no
CORS headers are sent at all.
The two methods
They are responsible for different things and are called at different moments:
configureServer() |
configureCors() |
|
|---|---|---|
| What it configures | The server itself: address, processes, memory, limits | The access policy for browsers |
| When it is called | Once, at startup | Once, at startup |
| When it applies | For the application’s whole life | On every request |
| What it receives | ServerSettings and ApplicationArguments |
CorsRegistry |
| Described in | Server settings below | CORS below |
configureServer(ServerSettings $server, ApplicationArguments $args) configures
the process the application lives in. It runs in the master process, before any
workers exist, so this is where things that cannot be changed later are set: the
port, the process count, the per-worker memory limit, the maximum request size, the
handling deadline. The second argument gives access to the parsed php call run
command line, so the source of a value is your choice: a flag, .env or a constant
in code.
configureCors(CorsRegistry $cors) describes which third-party origins the
browser will let read your application’s responses. The policy is assembled at
startup but applied to every response, 404s and errors included.
Do not override a method and nothing happens: the server starts with defaults, and CORS headers are simply not sent.
Several configurers
The two contracts behave differently, and the difference is arity rather than subject.
WebConfigurer — as many classes as you like, anywhere, packages
included. Every one receives the same CorsRegistry and the contributions add up: two
classes, each with its own allowedOrigins(), produce one combined list of origins. CORS
is a set of rules, and the union of two sets is meaningful.
ServerConfigurer — exactly one, and only in the application. ServerSettings is one
object mutated in place: two contributions do not add up, the second overwrites the first.
| Contract | Who may | How many |
|---|---|---|
WebConfigurer → configureCors() |
application and packages | any number, additive |
ServerConfigurer → configureServer() |
application only | one |
Only the application configures the server
A ServerConfigurer found inside an imported package is a boot error naming the class and
the package:
Only the application may configure the server; found AcmeBillingTuning in acme/billing.Otherwise a package tuning worker_num would quietly overrule the application’s choice —
and which of them won would come down to the order the filesystem was walked. A package
still has WebConfigurer: it may extend the CORS policy, but not move the bind address.
More than one ServerConfigurer in the application itself is an error too: the server has
one owner.
Order: the application is last
Configurers apply in a defined order — packages first, in #[Import] declaration order,
then the application. For additive registries this only affects the order of entries; where
contributions overlap, the application always wins.
Server settings
configureServer() is called once at startup, in the master process, before any
workers exist. Everything set here holds until the application stops: the address or
the memory limit cannot be changed on the fly.
public function configureServer(ServerSettings $server, ApplicationArguments $args): void
{
$server->port($args->int('port', 8000))
->workers(4)
->profile(Profile::Balance)
->requestTimeout(30)
->memoryLimit('256M');
}Calls chain — every method returns the settings object.
What is actually being configured here
Winter runs on Swoole, and that changes the meaning of almost every familiar number. The model is worth unpacking, otherwise it is unclear what each parameter is for.
The application lives in several workers — separate processes. Each worker boots PHP once and then serves requests without dying between them. And inside one worker requests do not queue up: each runs in its own coroutine, and while one waits on the database the worker serves the others.
Two consequences follow, and they determine every setting on this page:
- Memory is shared. All of a worker’s concurrent requests live in one heap. A request that eats too much kills not itself but the whole process — along with every request it was holding at that moment.
- Memory is not released by itself. The process does not end after a response, so whatever accumulated stays until the next request.
So the server settings are essentially the answer to one question: how many requests can a worker hold at once without risking a crash. Group by group:
Address
| Method | Default | Purpose |
|---|---|---|
host(string) |
0.0.0.0 |
The network interface the server listens on |
port(int) |
8000 |
The port |
0.0.0.0 means “every interface” — the server is reachable from outside. If the
application sits behind nginx on the same machine, narrowing it to 127.0.0.1 keeps
the port off the network.
The values are already filled from the --host and --port flags, so you only
touch these methods when you want the address to come from somewhere else.
Processes
| Method | Default | Purpose |
|---|---|---|
workers(int) |
the core count | How many processes serve requests |
taskWorkers(int) |
none | Separate processes for Swoole background tasks |
workers() is about using CPU cores, not about request concurrency. Concurrency
inside a worker comes from coroutines, and one process already holds hundreds of
requests. Workers exist to use every core: while one process computes, the others
work.
Remember that the memory limit multiplies by their number — four workers at 256M
require a gigabyte.
Recycling workers
| Method | Default | Purpose |
|---|---|---|
maxRequest(int) |
from the profile | How many requests a worker serves before being replaced by a fresh one. 0 — never |
maxRequestGrace(int) |
a tenth of maxRequest |
Jitter so workers do not all recycle at once |
A long-lived process accumulates garbage, and there is a remedy: periodically replace the worker with a new one. But the remedy is not free — recycling kills the requests the worker was serving, and the client sees a dropped connection while nothing appears in the logs.
So Winter measures rather than plays safe. Not every request leaks: measurements
on a live server showed zero heap growth across 4.6 million ordinary requests. A
180-byte leak arises only where a request arms a timer — Coroutine::sleep()
and everything built on it. Hence the threshold: a worker is replaced when the
accumulated leak could reach 20 % of its memory limit.
maxRequestGrace solves a separate problem: under even traffic workers reach the
same count at almost the same moment and recycle together — and every connection
pool goes cold at once with them. Swoole adds a random amount up to grace to the
limit, spreading the restarts out in time.
If your handlers never sleep, recycling can be switched off (maxRequest(0)) —
there would be nothing to pay dropped requests for.
Concurrent load
| Method | Default | Purpose |
|---|---|---|
maxConcurrency(int) |
from the profile | How many requests a worker handles at once |
maxConnections(int) |
from the profile | The ceiling on concurrent TCP connections |
idleConnectionTimeout(int) |
off | After how many silent seconds a connection is closed |
maxConcurrency() is the main safety valve. Every in-flight request occupies
memory: around 78 KB for the framing alone — the coroutine, the request and response
objects — plus whatever your code allocates. When there are too many concurrent
requests the worker hits its limit and dies together with all of them. The limit
turns that catastrophe into a queue: the excess simply waits.
Time spent in the queue counts towards the request deadline, so a stalled client does not get a fresh thirty seconds on top of the ones it already waited.
maxConnections() is the socket ceiling. A second constraint applies here,
unrelated to memory: a socket is a file descriptor, and their number is set by
ulimit -n. A value derived from the profile is trimmed to that limit so the
announced number matches reality. A value you set by hand is left alone: Swoole’s
warning about exceeding it reaches whoever asked for it and suggests raising
ulimit.
idleConnectionTimeout() is about keep-alive. An open connection occupies about
68 KB even when the client is asking for nothing, and that memory counts towards
PHP’s limit. A worker with the standard 128 MB dies at roughly 1,900 idle
connections. If you have many clients holding connections for a long time, set an
idle timeout.
Limits on one request
| Method | Default | Purpose |
|---|---|---|
maxRequestSize(int $bytes) |
8 MB | The largest request including headers; beyond it — 413 |
requestTimeout(float $sec) |
30 |
How long a request may run before the server stops waiting. 0 — no limit |
maxRequestSize(). A request body sits in the worker’s heap for the whole of
handling, and the heap is shared. So the limit here is not about disk or network but
about how many concurrent uploads the process survives: 64 MB with a hundred
parallel uploads is 6.4 GB, and the worker dies before the transfers finish. The
default matches PHP’s post_max_size so the familiar number is not surprising.
requestTimeout(). Swoole has no request timeout of its own — the deadline is
held by a watchdog that cancels the coroutine of a stalled request. The cancellation
is orderly: finally and defer run, transactions close, connections return to the
pool, the client receives 504.
The boundary matters: what is interrupted is a request that is waiting — on the database, an external service, a file. A request burning CPU in a loop with no I/O is not interrupted, because until it yields nothing runs in that worker, the watchdog included.
Individual routes override the deadline with the #[Timeout] attribute — see
Routing.
Memory
| Method | Default | Purpose |
|---|---|---|
memoryLimit(string) |
PHP’s ini value | The memory ceiling of each worker |
memoryTrimThreshold(string) |
from the profile | How much idle memory a worker holds before returning it to the system. 0 — never return |
memoryLimit() is PHP’s memory_limit, applied when each worker starts. Almost
everything else is computed from it: the allowed concurrency, the worker-recycling
threshold, the memory-return threshold.
The memory limit is per worker, not per application
memoryLimit('512M') with eight workers means 4 GB in the worst case. Winter
computes that product at startup and warns when the machine cannot hold it, but does
not block the start: over-committing is sometimes a deliberate choice.
memoryTrimThreshold() is about returning memory to the operating system. After
a load spike a worker keeps holding what it allocated even though it is no longer
needed. A check after each response returns the surplus, but the operation is not
free: with a lot of memory it takes around 80 ms and shows up in the latency tail.
So the threshold scales from the limit rather than being a fixed number.
Static files
$server->staticPath('resources/static'); // resources/static/app.css → /app.cssServing static files is off by default — until a directory is named, no file is served.
Static files bypass PHP
Such files are served by Swoole itself, before control reaches PHP. So middleware, CORS and request logging do not apply to them — routing knows nothing about them at all.
Everything else
Any Swoole option without a dedicated method is set directly:
$server->set('open_http2_protocol', true);Profiles
Setting limits one by one is rarely necessary: nearly all of them are derived from a profile.
A profile is a way of not calculating interrelated limits by hand. It answers a single question: how much memory one of your requests uses. Everything else follows arithmetically — how many requests a worker can hold at once, how many connections to open, when to return memory to the system.
$server->profile(Profile::Balance);All three working profiles are equally reliable. They do not trade stability for speed — on the contrary, each exists precisely so that a worker does not overstretch its memory and die taking its in-flight requests with it. They differ only in the assumption about request size:
| Profile | Request budget | For which applications |
|---|---|---|
Stable |
512 KB | Heavy requests: reports, exports, wide result sets |
Balance |
128 KB | Ordinary CRUD — the default |
Performance |
64 KB | Light requests: a thin API, a proxy, an integration gateway |
The budget is the memory for the request’s own work: loaded entities, the assembled response. The framing (about 78 KB per request and 68 KB per connection) is added on top and does not depend on the profile.
A smaller budget means greater concurrency: with the same memory, Performance lets
three times as many requests run at once as Stable. So choosing a profile is not
“how cautious am I” but “how large are my requests”. And that is measured, not
guessed:
$before = memory_get_usage();
// ... the handler ...
$after = memory_get_usage(); // under 64 KB → Performance; over 200 KB → StableChoosing wrongly does not make the application safer: if requests are heavier than
declared, Performance will not save you — it merely lets more of them run at once
than the worker can bear.
`Stress` is for measurement only
The fourth profile, Profile::Stress, is the only one that removes the
safeguards: no concurrency limit, no worker recycling, no memory return. It exists
not to “squeeze out more” — throughput hits its ceiling long before memory does. It
removes the periodic interference that distorts a measurement: memory return
introduces pauses visible in p99, worker recycling empties the connection pool
mid-run, the request watchdog spins a timer of its own.
Under this profile a worker can exhaust its memory, and over a long run the leak accumulates with no replacement. For a time-boxed benchmark that is acceptable; for production it is not.
Everything a profile decides is a default. An explicit maxConcurrency(),
maxRequest() or an environment variable overrides it regardless of call order.
Configuring through .env
The same parameters are set by environment variables — convenient when the values differ between staging and production:
| Variable | Corresponds to |
|---|---|
SERVER_PROFILE |
profile() |
SERVER_WORKERS |
workers() |
SERVER_TASKS |
taskWorkers() |
SERVER_MAX_REQUEST |
maxRequest() |
SERVER_MAX_REQUEST_GRACE |
maxRequestGrace() |
SERVER_MAX_REQUEST_SIZE |
maxRequestSize() |
SERVER_MAX_CONNECTIONS |
maxConnections() |
SERVER_MAX_CONCURRENCY |
maxConcurrency() |
SERVER_IDLE_TIMEOUT |
idleConnectionTimeout() |
SERVER_MEMORY_LIMIT |
memoryLimit() |
SERVER_MEMORY_TRIM |
memoryTrimThreshold() |
SERVER_REQUEST_TIMEOUT |
requestTimeout() |
Priority runs from explicit to derived: a call in configureServer() overrides
an environment variable, and the variable overrides the profile’s value. Call order
does not matter — the profile fills in only where nothing was said.
How all this shows up in the execution model — coroutines, shared state, differences from PHP-FPM — is on the Runtime page.
CORS
CORS decides which third-party origins the browser will let reach your API. Winter
offers two levels: a global policy for the whole application and a per-route
override through the #[CrossOrigin] attribute.
What CORS is and why
CORS — Cross-Origin Resource Sharing — is the mechanism by which a server allows a browser to hand a response to code from another origin.
An origin is the triple “scheme + host + port”: https://app.example.com and
https://api.example.com are different origins. By default browsers enforce the
same-origin policy: JavaScript on a page from one origin cannot read a response
from another. That is what stops a stranger’s site from calling your API on the
user’s behalf.
The problem. The typical “SPA + API” pairing lives on two origins: the frontend
on app.example.com, the API on api.example.com. By default the browser
blocks the frontend’s request to the API — with the familiar console message
CORS policy: No 'Access-Control-Allow-Origin' header.
The solution. The server must tell the browser explicitly which origins it
trusts, through response headers (Access-Control-Allow-Origin and relatives).
Winter sets those headers for you: globally for the whole application and
locally on a particular route.
CORS is a browser thing
CORS is enforced by the browser, not the server. Requests from curl, Postman
or backend-to-backend are not subject to the same-origin policy — CORS headers do
not affect them.
The global policy
Set in configureCors(). CorsRegistry is a fluent builder: each method takes a
comma-separated list of values (not an array) and returns itself, so calls chain.
| Method | Default | What it does |
|---|---|---|
allowedOrigins(...$origins) |
empty | Permitted origins |
allowedHeaders(...$headers) |
empty | Headers the browser may send |
exposeHeaders(...$headers) |
empty | Response headers visible to JavaScript |
allowCredentials(bool) |
false |
Permits cookies and Authorization |
maxAge(int $seconds) |
0 |
How long the browser caches the preflight |
vary(...$headers) |
empty | Additional values for the Vary header |
Touch nothing and no CORS is sent at all. An empty configureCors() is the same
as having no configurer: every response stays same-origin.
How the settings behave
A few details visible only in the resulting headers.
allowedOrigins works in three modes, depending on how many there are:
| Origins | What goes into the response |
|---|---|
| none | Access-Control-Allow-Origin: * |
| exactly one | That origin, always, with no Vary |
| several | The matching Origin from the request plus Vary: Origin |
The third mode exists so that intermediate caches do not hand a response issued for one origin to another.
allowedHeaders and maxAge go out on preflight only. They are absent from
ordinary responses, and by specification they are not needed there. If
allowedHeaders is not set, the framework reflects what the browser asked for in
Access-Control-Request-Headers.
allowCredentials() requires explicit origins. The specification forbids the
pairing of * with credentials, and Winter simply does not send that header: with
an empty allowedOrigins() a call to allowCredentials() silently does
nothing. The browser shows no error either — a request with cookies just fails.
// ✗ the Allow-Credentials header will not be sent
$cors->allowCredentials();
// ✓ origins listed explicitly
$cors->allowedOrigins('https://app.example.com')
->allowCredentials();vary() replaces the header wholesale. When there are several origins the
framework has already set Vary: Origin, and your vary() overwrites it. List
Origin yourself:
$cors->allowedOrigins('https://app.example.com', 'https://admin.example.com')
->vary('Origin', 'Accept-Language');Ready-made configurations
Three cases that cover almost everything.
A public read-only API — reachable from anywhere, no cookies:
$cors->allowedHeaders('Content-Type')
->maxAge(86400);An SPA with authentication — one known frontend, cookies and tokens allowed:
$cors->allowedOrigins('https://app.example.com')
->allowedHeaders('Content-Type', 'Authorization')
->allowCredentials()
->maxAge(3600);Several frontends — a list of origins, the response adapts to the request:
$cors->allowedOrigins(
'https://app.example.com',
'https://admin.example.com',
)
->allowedHeaders('Content-Type', 'Authorization')
->allowCredentials()
->maxAge(3600);A policy on one route
When a single endpoint needs a stricter or looser policy than the rest,
#[CrossOrigin] goes on the controller or the method. The same six parameters, as
named arguments:
use Flytachi\Winter\Kernel\Route\Annotation\CrossOrigin;
#[CrossOrigin(origins: ['https://admin.example.com'], credentials: true)]
#[RequestMapping('admin')]
class AdminController extends Controller
{
#[GetMapping('stats')]
public function stats(): ResponseEntity { /* admin.example.com */ }
#[GetMapping('feed')]
#[CrossOrigin(origins: ['https://partner.example.com'], maxAge: 3600)]
public function feed(): ResponseEntity { /* partner.example.com */ }
}Priority runs from specific to general: the method overrides the class, the class overrides the global policy.
| Attribute argument | Builder method |
|---|---|
origins |
allowedOrigins() |
allowHeaders |
allowedHeaders() |
exposeHeaders |
exposeHeaders() |
credentials |
allowCredentials() |
maxAge |
maxAge() |
vary |
vary() |
It replaces, it does not extend
#[CrossOrigin] completely displaces the global policy for its route rather
than merging with it. If Authorization is allowed globally but the attribute names
only origins, then on that route Authorization ends up forbidden.
The practical rule: when adding #[CrossOrigin], carry over every parameter you
still need from the global policy.
Preflight requests
Before a “non-simple” request — one with an unusual method, an Authorization
header or a JSON body — the browser first sends OPTIONS and asks permission. The
framework handles that itself: it answers 204 with the right headers before
middleware and the controller. You do not write an OPTIONS method.
To answer correctly the router first works out which route the browser intends to
call — from the Access-Control-Request-Method header — and takes that route’s
#[CrossOrigin] if it has one.
Where the headers appear
Global headers are written before route lookup, so they are present on any
response: successful, 404, 405 and 5xx errors alike.
That is deliberate. If a 404 carried no headers, the browser would hide the real
status from JavaScript and show a CORS error instead — and the developer would go
looking for a policy problem instead of a typo in the address.
Next
- Runtime — how coroutines and shared state change the model
- Routing — where
#[CrossOrigin]lives - Middleware — your own pre- and post-processing
- Controllers — what the policy protects