Getting started

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.

Your code main/Service data storage/Not scanned vendor · storage · resources

After the install

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

bootstrap.php
#[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.

bash
php call run dev          # development server
php call mapping show     # the route list
php call make -c .User    # create a controller

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

composer.json
{
  "autoload": {
      "psr-4": {
          "Main\\": "main/"
      }
  }
}

A single Main\ prefix is not the limit. Add your own and they start working after composer dump-autoload:

composer.json
"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.

bash
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_KEY

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

text
main/
├── MainController.php
├── User/
│   ├── UserController.php
│   ├── UserService.php
│   └── UserRepository.php
└── Order/
  ├── OrderController.php
  └── OrderProcess.php

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

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

bash
php call make -c .User        # main/UserController.php
mkdir main/Controllers
php call make -c .Order       # main/Controllers/OrderController.php

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

  1. every package imported through #[Import], in declaration order;
  2. then the project root — whole, from bootstrap.php down.

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.

text
storage/runnable/
└── Main.Daemon.Emails/
  └── 2bb0b4ff2c7e…      PID, state, start time, restart count

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

bash
php call storage init     # create the directories
php call storage clean    # empty them, keeping cache/ and logs/ themselves

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

text
<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] proxies

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

bash
php call di build     # build the cache and check the #[Async] contracts
php call di clean     # delete the cache and every proxy

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

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

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

The 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():

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