Web basics

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.

Class WebConfigurerAdapterServer configureServer()CORS configureCors()

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:

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

ServerConfigurerexactly 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
WebConfigurerconfigureCors() application and packages any number, additive
ServerConfigurerconfigureServer() 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:

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

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

php
$server->staticPath('resources/static');   // resources/static/app.css → /app.css

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

php
$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.

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

php
$before = memory_get_usage();
// ... the handler ...
$after = memory_get_usage();   // under 64 KB → Performance; over 200 KB → Stable

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

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

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

php
$cors->allowedHeaders('Content-Type')
   ->maxAge(86400);

An SPA with authentication — one known frontend, cookies and tokens allowed:

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

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

php
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