Project structure
A Winter project is flat: four files at the root and one directory for your code.
There is no config/, no routes/, no mandatory
app/. The layout inside your own directory is yours to choose — the
framework finds classes by walking files, not by reading a list in a config.
After installation
my-app/
├── bootstrap.php the application class: what it consists of
├── call the entry point for every command
├── composer.json autoloading and dependencies
├── .env environment variables
│
├── main/ your code, namespace Main\
│ └── MainController.php
│
├── storage/ service data, never committed
│ ├── cache/
│ └── logs/
│
└── vendor/Everything else appears when needed and is created by you: resources/ for
templates and translations, docker/ for the container, directories inside main/
for however you prefer to arrange classes.
The files at the root
bootstrap.php
The application class. It declares what the application consists of and serves as the entry point.
#[EnableWeb]
final class Application extends WinterApplication
{
public static function main(array $argv): never
{
parent::run($argv);
}
}The #[Enable*] attributes are the only place the application’s make-up is listed.
See Components.
call
Runs everything: the server, console commands, generators. Inside are three lines:
pull in the autoloader, pull in bootstrap.php, hand control to the application
class.
php call run dev # development server
php call mapping show # the route table
php call make -c .User # create a controllerThe file contains chdir(__DIR__), so commands work from any directory:
php /path/to/my-app/call run starts the server with the right paths.
composer.json
Besides dependencies it defines PSR-4 — which namespace maps to which directory:
{
"autoload": {
"psr-4": {
"Main\\": "main/"
}
}
}.env
Values that differ between environments. The full list is on the Configuration page. Not committed.
main/ — your code
The one directory you fill. Its internal structure is yours: the framework demands
neither Controllers/ nor Models/ nor any other name.
main/
├── MainController.php
├── User/
│ ├── UserController.php
│ ├── UserService.php
│ └── UserRepository.php
└── Order/
├── OrderController.php
└── OrderProcess.phpHere classes are grouped by domain rather than by type — but a by-type layout
(Controllers/, Services/, Repositories/) works exactly the same. To the
framework there is no difference.
Several roots
One Main\ prefix is not the limit. Add your own to composer.json and they start
working after composer dump-autoload:
"psr-4": {
"Main\\": "main/",
"Api\\": "api/",
"Admin\\": "admin/"
}Separate roots are convenient when parts of the application live their own lives: a public API and an admin panel have different routes, different middleware and often different people.
Where make puts files
The generator works from the namespace, not from the type: the file path is derived from the name you give it.
php call make -c .User # main/UserController.php
php call make -c main.api.User # main/Api/UserController.phpIf a conventionally named directory already exists, the generator puts the file there:
| Type | Looks for |
|---|---|
| Controller | Controllers/, Controller/ |
| Service | Services/, Service/ |
| Middleware | Middlewares/, Controllers/Middlewares/ |
| Repository | Repositories/, Repository/ |
| Entity, DTO | Entity/, Entity/Dto/ |
| Process, daemon | Processes/, Daemons/ |
No such directory — the file lands next to it, by namespace. You do not need to create them up front.
What gets scanned
At startup the framework walks the whole project from the root, reads every
.php and loads the ones that declare a class. That is how controllers,
configurations and processes are found — none of them needs registering.
Three directories are excluded from the walk:
| Directory | Why |
|---|---|
vendor/ |
Dependencies take no part in finding application classes |
storage/ |
It holds generated code — the walk would read the previous walk’s output |
resources/ |
It holds templates: PHP files by nature, not classes |
Templates are excluded for correctness, not speed
The scanner looks for a class declaration in every .php and loads the file where
it finds one. For a template that means executing it: it prints its markup into
the application’s output and runs whatever sits at its top level. This was observed
on a live project, not theorised.
So keep templates under resources/. A template directory placed inside main/
falls under the walk.
Hence the practical rule: anything that is not an application class belongs in
resources/ or storage/. Scripts, dumps and one-off migration files at the
project root will be read and, if they declare a class, loaded.
storage/ — service data
| Directory | What is there | Created by |
|---|---|---|
storage/cache/ |
Application cache — data you put there yourself | storage init |
storage/logs/ |
Log files when LOG_OUTPUT=file |
storage init |
storage/runnable/ |
Lock files for single-instance processes | Appears when such a process first runs |
The whole directory is in .gitignore, but the .gitignore file itself is
committed — so the structure comes back after git clone.
php call storage init # create the directories
php call storage clean # empty them, keeping cache/ and logs/The boot cache does not live here
The class list (di.php), the #[Async] proxies and everything else the framework
generates at startup are written to the system temporary directory by default —
/tmp/flytachi.winter.volatile.<project-name> — not to storage/.
That keeps generated code out of the image and out of the way across reboots. The
practical consequence: clearing storage/ to reset it is pointless — use
php call di clean.
You can move it inside the project with isTmpVolatile: false in Kernel::init(),
but then the directory must be writable by the user the application runs as.
resources/ — templates and translations
The directory does not exist initially; create it when you need it.
| Path | What is there |
|---|---|
resources/views/ |
Templates and layouts for ResponseView |
resources/lang/ |
Translation dictionaries: ru.php, en.php |
resources/static/ |
Static files, if the application serves them |
Serving static files is switched on explicitly, in the web-layer configuration:
public function configureServer(ServerSettings $server): void
{
$server->staticPath('resources/static');
}Those requests are served directly and never reach PHP: middleware, CORS and request logging do not apply to them.
Container files
php call cfg docker adds a ready-made build to the project:
my-app/
├── Dockerfile
├── docker-compose.yml
├── .dockerignore
└── docker/
├── entrypoint.sh startup: dev or production
├── php-opcache.ini opcache settings for production
├── php-memory.ini
└── dependencies/ your package-installation scriptsThe mode is chosen by a variable: DEV=true docker compose up for development with
restart-on-change, docker compose up for production with opcache.
PHP extensions and database drivers are installed by the scripts in dependencies/:
what ships with it, how to drop what you do not need and how to write your own is in
cfg docker.
Custom paths
The default layout is derived from where the application class sits. If it does not
suit you, override it in configure():
protected static function configure(ApplicationArguments $args): void
{
Kernel::init(
pathRoot: __DIR__,
pathResource: __DIR__ . '/assets',
pathStorage: '/var/lib/my-app',
);
}Moving storage outside the project is usually needed in a container, where the
code sits on a read-only layer and writes have to go to a mounted volume.
Next
- Configuration —
.envand configurer classes - Components — the
#[Enable*]attributes - Quick start — your first route
- Console —
make,storage,cfg