Quick start
One route from nothing to a visible result: create a project from the base build, add a controller, bring the server up and get a JSON response. Below, in detail, what happens at each step — this is the first page of working with the framework.
What flytachi/winter is
There are two packages, and they should not be confused.
| Package | What it is | When you take it |
|---|---|---|
flytachi/winter-kernel |
The kernel. A library: routing, container, processes, console | Fitting it into an existing project |
flytachi/winter |
The base build. A ready project skeleton with the kernel already wired | A new project — the usual case |
The kernel is a type: library: it creates nothing on disk and knows nothing about how
your project is laid out. Building the skeleton by hand is possible, and
Installation shows how — file by file.
The base build spares you that. flytachi/winter is a type: project: composer does
not put it in vendor/, it unrolls it as your project, after which the build is out of
the picture. You get a working skeleton and write your code in it; what gets updated later
is the kernel, not the build.
1. The project
composer create-project flytachi/winter my-app
cd my-appHere is what unrolls:
my-app/
├── bootstrap.php the application class: what it consists of
├── call the entry point for every command, the server included
├── composer.json autoloading Main\ → main/ and the kernel dependency
├── .env WINTER_KEY, TIME_ZONE, DEBUG
├── main/
│ └── MainController.php an example controller
└── storage/
├── cache/
└── logs/Three things the build does for you right after installing — its
post-create-project-cmd:
| Step | What happens |
|---|---|
call storage init |
creates storage/cache and storage/logs |
chmod -R 777 storage |
so both the web process and the console can write there |
call cfg init |
creates .env and generates your own WINTER_KEY |
The key is generated on the spot rather than shipped in the repository — it signs data handed to background processes, and one key shared by two projects would be a hole.
There is no `public/` and no config directory
There is no entry point for a web server: the application is the server, and it has no
document root. There is no configuration directory either — what other frameworks keep in
config/*.php is either an environment variable or an ordinary class the scan finds. More
in Project structure.
2. The controller
Generate a controller with make. The leading dot is required; the generator appends the
Controller suffix itself:
php call make -c .Greet # → main/GreetController.phpBring it to this shape — the route GET /api/hello/{name} returning JSON:
<?php
namespace Main;
use Flytachi\Winter\Kernel\Http\Request\Annotation\PathVariable;
use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
use Flytachi\Winter\Kernel\Http\Stereotype\Controller;
use Flytachi\Winter\Kernel\Route\Annotation\GetMapping;
use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping;
#[RequestMapping('api')]
class GreetController extends Controller
{
#[GetMapping('hello/{name}')]
public function hello(#[PathVariable] string $name): ResponseEntity
{
return ResponseEntity::ok(['message' => "Hello, {$name}"]);
}
}What is going on here:
#[RequestMapping('api')]on the class — the/apiprefix shared by every method.#[GetMapping('hello/{name}')]— the routeGET /api/hello/{name}.#[PathVariable]binds the{name}segment to the$nameargument.ResponseEntity::ok([...])answers200with a JSON body.
The route needs registering nowhere: at startup the application walks the project and assembles the route table from the attributes. The file is there, so the route is there.
3. Running it
Through Docker — the recommended way
Winter’s web tier runs on Swoole, and database drivers and a Redis client usually join it. None of that needs installing into your system for a first route: the build can generate its own environment.
php call cfg docker # Dockerfile, docker-compose.yml, docker/
DEV=true docker compose upThe first command puts four things into the project:
| File | What for |
|---|---|
Dockerfile |
an image on phpswoole/swoole — Swoole and opcache already inside |
docker-compose.yml |
the port, the source mount, the DEV switch |
docker/entrypoint.sh |
decides how the application starts inside the container |
docker/dependencies/*.sh |
drivers: bcmath, pgsql, mysql, redis — delete what you do not need |
DEV=true changes the container’s behaviour wholesale:
DEV=true |
without the variable (production) | |
|---|---|---|
| Command inside | call run dev |
call run |
| Sources | mounted, an edit is live | from the image |
| opcache | off | on and tuned |
| Before starting | — | call di build; if it fails the container will not come up |
The sources are mounted into the container, so you edit them in your own editor and the
application restarts itself. The port is one number — SERVER_PORT in the environment or
in .env, 8000 by default.
What that needs on your machine
Docker only. Neither Swoole nor pdo_pgsql nor phpredis has to be installed in your
system — they live in the image. PHP on the host is not required either: doing without it
entirely is shown below.
If there is no PHP on the machine at all
Both commands above — create-project and cfg docker — are themselves written in PHP.
But they can run in a throwaway container: the official composer image carries PHP
inside, and the project lives in a mounted directory that stays with you after the
container is gone.
Unroll the project:
docker run --rm -v "$PWD":/app -w /app composer:2 create-project flytachi/winter my-app --ignore-platform-reqs--ignore-platform-reqs here is not “silence the error”. The kernel requires ext-pcntl
and ext-posix — they are needed by the running application, not by the installer, and
the runtime image has them. Without the flag composer refuses to install into an image that
does not:
flytachi/winter-kernel[v4.0.0, ..., v4.1.0] require ext-pcntl *
-> it is missing from your system.The install completes in full, the build steps included: the directories are created, the
.env is written, the WINTER_KEY is generated.
The console goes the same way. The composer image’s entry point is composer itself,
so it is replaced with php:
cd my-app
docker run --rm -v "$PWD":/app -w /app --entrypoint php composer:2 call cfg docker
docker run --rm -v "$PWD":/app -w /app --entrypoint php composer:2 call make -c .Greet
docker run --rm -v "$PWD":/app -w /app --entrypoint php composer:2 call mapping showThe line is long, so it usually goes into an alias — and from then on it is just
winter make -c .Greet:
alias winter='docker run --rm -v "$PWD":/app -w /app --entrypoint php composer:2 call'A throwaway container is for generators, not for the server
It has no Swoole, so call run there refuses honestly:
`call run` with a web tier needs ext-swoole (pecl install swoole).And that is the right division of labour: generators and one-off commands in a light
container, the application itself in its own image through docker compose up, where
Swoole is already built.
Once the application is up, no separate container is needed — the command runs inside the running one:
docker compose exec server php call mapping show
docker compose exec server php call db migrateThat is the only way to run what needs the application’s live environment: migrations,
db ping, db pool.
Locally, if PHP is already set up
php call run devphp call runExactly one thing separates them — watching the files:
| Command | Behaviour |
|---|---|
call run |
a plain start; a .php edit is picked up only by a restart |
call run dev |
a watcher follows .php files and restarts the application itself |
Development is done on run dev: the route table and the class list are assembled once
at startup, so without a restart the server does not see a new controller.
The address and port are overridden by flags:
php call run dev --port=9000
php call run dev --host=127.0.0.1 --port=9000 # local onlyThis way needs PHP 8.4+ with ext-swoole; without it call run refuses to start and says
so. The full list of requirements is on
Installation.
4. The result
curl http://localhost:8000/api/hello/Winter{"message":"Hello, Winter"}The same address opens in a browser and shows the same JSON.
Route not found?
Look at what actually registered:
php call mapping show
If your route is not in the list, the controller did not reach the scan. Check that the
file is not under resources/ or storage/ (those are excluded), that the class is not
abstract, and that the method is public.
5. A little more: a query parameter
Add an optional ?shout=true:
#[GetMapping('hello/{name}')]
public function hello(
#[PathVariable] string $name,
#[RequestParam] bool $shout = false,
): ResponseEntity {
$message = "Hello, {$name}";
return ResponseEntity::ok(['message' => $shout ? strtoupper($message) : $message]);
}curl "http://localhost:8000/api/hello/Winter?shout=true"{"message":"HELLO, WINTER"}The default value makes the parameter optional, and the bool type makes it mandatory to
parse: true, 1, yes, on give true, anything else meaningful gives false, and a
value that makes no sense answers 400 before the method is entered.
The whole thing
<?php
namespace Main;
use Flytachi\Winter\Kernel\Http\Request\Annotation\PathVariable;
use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestParam;
use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
use Flytachi\Winter\Kernel\Http\Stereotype\Controller;
use Flytachi\Winter\Kernel\Route\Annotation\GetMapping;
use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping;
#[RequestMapping('api')]
class GreetController extends Controller
{
#[GetMapping('hello/{name}')]
public function hello(
#[PathVariable] string $name,
#[RequestParam] bool $shout = false,
): ResponseEntity {
$message = "Hello, {$name}";
return ResponseEntity::ok(['message' => $shout ? strtoupper($message) : $message]);
}
}Next
The route works — from here the road forks by what you are building.
A web application
The request arrived, the response left, and everything in between:
- Routing — verbs, prefixes, path parameters
- Controllers — what to return and how dependencies arrive
- Requests and parameter binding — bodies, headers, files
- Validation — checking input before the method is entered
- Responses — JSON, files, streams, codes and headers
- Error handling — what the client sees when something breaks
Background work
Everything that outlives a request or starts without one:
- Components — what can be declared beside the web tier
- Processes — an import, a mailing, a queue consumer
- Daemons — a fleet of workers with restarts and scaling
- Scheduler —
#[Scheduled]: by interval or by calendar - Asynchronous calls — parallel calls inside one request
Data
Installed as separate packages; the kernel does not pull them:
- PHP Persistence API — repositories, entities, migrations
- Redis — stores, hashes, lists, streams with a connection pool
- Basic connections — when you want a connection but not a package
Understanding how it works
- Key concepts — how the scan, the container and the stereotypes relate
- Dependency injection — where a controller’s services come from
- Configuration —
.env, the application manifest, configurers - Philosophy — why the decisions are what they are