Project structure
A Winter project is flat: a handful of 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 the install
my-app/
├── bootstrap.php the application class: what it is made of
├── call the entry point for every command
├── composer.json autoloading and dependencies
├── composer.lock pinned versions
├── .env environment variables
├── .gitignore
│
├── main/ your code, namespace Main\
│ └── MainController.php
│
├── storage/ service data; the contents stay out of the repository
│ ├── cache/
│ └── logs/
│
└── vendor/That is everything composer create-project leaves behind. The rest appears when it is
needed:
| What | When it appears |
|---|---|
storage/runnable/ |
the first time a process or daemon starts |
resources/ |
you create it — for views, translations, static files |
Dockerfile, docker-compose.yml, docker/ |
php call cfg docker |
directories inside main/ |
you create them, or the make generator does |
The project 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 single place where the application’s composition is
listed. See Application composition.
This file also fixes the project root: the framework takes the directory the
application class lives in and derives every other path from it — .env, storage,
resources, the starting point of the file walk. That is why bootstrap.php sits at
the root rather than in a subdirectory.
call
It runs everything: the server, the console commands, the generators. Inside are a PHP
version guard, a chdir(__DIR__), the bootstrap.php include, and the handover to the
application class.
php call run dev # development server
php call mapping show # the route list
php call make -c .User # create a controllerThe chdir inside means commands work from any directory:
php /path/to/my-app/call run starts the server with the right paths.
composer.json
Besides the dependencies it sets up PSR-4 — which namespace maps to which directory:
{
"autoload": {
"psr-4": {
"Main\\": "main/"
}
}
}A single Main\ prefix is not the limit. Add your own 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. Every root is scanned the same way — the framework walks the project whole, not the listed paths.
.env
The environment-dependent values. It is not committed — it and vendor/ are what the
.gitignore covers.
php call cfg env -s # what actually loaded
php call cfg env -i # create .env from the template
php call cfg key -g # reissue the WINTER_KEYThe full list of variables is on the Configuration page.
main/ — your code
The only directory you fill. Its inner structure is yours: the framework requires
neither Controllers/ nor Models/ nor any other name.
main/
├── MainController.php
├── User/
│ ├── UserController.php
│ ├── UserService.php
│ └── UserRepository.php
└── Order/
├── OrderController.php
└── OrderProcess.phpHere the classes are grouped by domain rather than by type — but a by-type layout
(Controllers/, Services/, Repositories/) works exactly the same. It makes no
difference to the framework: it looks for classes, not for directories.
Where make puts files
The generator goes by namespace. There are only two rules.
Give it a dotted path and the path decides. It is resolved against the PSR-4 roots,
with vendor/ excluded:
php call make -c api.user.Profile # main/Api/User/ProfileController.php
php call make -c admin.Report # admin/ReportController.php (given an Admin\ root)Give it no path (.Name) and it looks for a directory you already use. The first
existing one from the list wins:
| Type | Flag | Looks for (first existing) |
|---|---|---|
| Controller | -c |
Rests, Rest, Controllers, Controller |
| Middleware | -m |
Middlewares, Middleware, Controllers/Middlewares, Controller/Middleware |
| Service | -s |
Services, Service |
| Repository | -r |
Repositories, Repository |
| Redis store | -t |
Stores, Store |
| Entity | -e |
Entity, Entities |
| DTO | -d |
Dto, DTOs, Entity/Dto, Entities/Dto |
| Process | -P |
Processes, Process |
| Daemon | -N |
Daemons, Daemon |
| Database config | -D |
Configs/Databases, Config/Database, Configs, Config |
| Redis config | -R |
Configs/Redis, Configs, Config |
| Console command | -n |
Cmd |
If none of them exists the file lands in the PSR-4 root itself, that is, straight in
main/. There is nothing to create in advance: make the one directory you like once,
and the generator will keep finding it.
php call make -c .User # main/UserController.php
mkdir main/Controllers
php call make -c .Order # main/Controllers/OrderController.phpThe --mvc option does not look, it creates: it prepends the by-type directory
(Controllers/, Services/, Repositories/…) whether or not it is already there. The
details and every flag are in call make.
What gets scanned at boot
The framework does not read a list of classes from a config — it walks files. The order is:
- every package imported through
#[Import], in declaration order; - then the project root — whole, from
bootstrap.phpdown.
The application goes last on purpose: where a package’s contribution and the application’s conflict, the application must be the one that wins.
In each directory only .php files are read. Every class declared in a file is
collected, and the file is then required. That is how controllers, configurations,
processes and commands are found — none of them has to be registered anywhere.
Three directories are left out of the walk:
| Directory | Why |
|---|---|
vendor/ |
Dependencies take no part in finding application classes; excluded by the scanner itself |
storage/ |
It holds generated code — the walk would be reading the previous walk’s output |
resources/ |
It holds views: PHP files by nature, not classes |
The exclusions are bound to the configured paths, not to the names: move the resources
to assets/ through pathResource and the exclusion moves with them.
Views are excluded for correctness, not for speed
The scanner looks for a class declaration in every .php and requires the file it found
one in. For a template that means execution: it echoes its markup into the
application’s output and runs whatever else sits at its top level. Verified on a live
project, not assumed.
So keep templates under resources/. A template directory placed inside main/ falls
into the walk.
Hence the practical rule: anything that is not an application class belongs in
resources/ or storage/. Scripts, dumps and migration one-liners at the project
root will be read and, if they declare a class, required.
storage/ — service data
| Directory | What is in it | Who creates it |
|---|---|---|
storage/cache/ |
Whatever you put there yourself via Kernel::store() |
storage init |
storage/logs/ |
Log files when LOG_OUTPUT=file |
storage init |
storage/runnable/ |
The state of running processes and daemons | The first run of such a process |
runnable/ holds records rather than locks: one subdirectory per class, named with the
dotted form of the class name.
storage/runnable/
└── Main.Daemon.Emails/
└── 2bb0b4ff2c7e… PID, state, start time, restart countThis is what call process status and call daemon list read; because the directory is
shared, the console sees a process started from the web application and the other way
round. winter-ppa writes ppa.pool here too — the
connection-pool statistics behind /actuator/health.
The directory’s contents stay out of the repository while its structure goes in:
storage/.gitignore ignores everything except itself, cache/ and logs/, so a fresh
git clone still has the directories.
php call storage init # create the directories
php call storage clean # empty them, keeping cache/ and logs/ themselvesThe boot cache does not live in storage/
Everything the framework generates for itself is written to the system temp directory by default, not into the project:
<sys_get_temp_dir()>/flytachi.winter.volatile.<project-directory-name>
├── di.php the list of discovered classes
├── async.php the list of classes carrying #[Async]
└── async/ the generated #[Async] proxiesOn Linux and inside a container that is /tmp/flytachi.winter.volatile.my-app; on macOS
the temp directory is its own, somewhere under /var/folders/…. No need to guess —
php call di prints the exact paths.
It is done this way so the generated code never ends up in an image and never survives a
reboot. The practical consequence: clearing storage/ to reset this cache achieves
nothing — there is a separate command.
php call di build # build the cache and check the #[Async] contracts
php call di clean # delete the cache and every proxyWith DEBUG=true there is no cache at all
The cache is written and read only when DEBUG=false. In debug every boot walks the
files again — a new class shows up immediately, with no di build and no “why is my
controller not found”.
The flip side of the same thing: in production a class added without rebuilding the
cache will not appear. That is why di build is an image build step, not a manual
operation.
The directory can be moved inside the project with isTmpVolatile: false in
Kernel::init() — it then becomes storage/volatile/, and it must be writable by the
user the application runs as.
resources/ — views, translations, static files
The directory does not exist at first; create it when you need it. The subdirectory names are the framework’s convention and there is no reason to change them.
| Path | What is in it |
|---|---|
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 settings:
public function configureServer(ServerSettings $server): void
{
$server->staticPath('resources/static');
}Those requests are served by the server directly and never reach PHP: middleware, CORS and request logging do not apply to them.
The 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/ extension install scripts
├── 10-bcmath.sh
├── 20-pgsql.sh
├── 30-mysql.sh
└── 40-redis.shThe mode is picked by a variable: DEV=true docker compose up is development with a
restart on file change, docker compose up is the production mode with opcache.
The scripts in dependencies/ run at image build time in numeric order. Delete the ones
you do not need and add your own — how exactly is described in
cfg docker.
Your own paths
The default layout is derived from the root, and the root from where the application
class sits. All of it is overridable in configure():
protected static function configure(ApplicationArguments $args): void
{
Kernel::init(
pathRoot: __DIR__,
pathResource: __DIR__ . '/assets',
pathStorage: '/var/lib/my-app',
);
}| Parameter | Default |
|---|---|
pathRoot |
the directory of the file holding the application class |
pathResource |
<root>/resources |
pathStorage |
<root>/storage |
pathStorageLog |
<storage>/logs |
pathStorageCache |
<storage>/cache |
pathStorageRunnable |
<storage>/runnable |
isTmpVolatile |
true — the boot cache goes to the system temp directory |
Moving storage outside the project is usually a container requirement, where the code
sits on a read-only layer and the writes have to go to a mounted volume. Overriding the
paths one at a time rather than all at once is fine: what you leave out is derived from
what you set.
Next
- Configuration —
.envand the configurer classes - Application composition — the
#[Enable*]attributes - Quickstart — the first route
call make— the generators and every flag- Packages —
#[Import]and other people’s code in the walk