Advanced

Packages

A package is an ordinary Composer dependency whose code takes part in the life of the application: controllers, commands, beans, entities, scheduled tasks. Taking part is the only thing that separates it from a library, and it is switched on with one line — #[Import].

Wiring #[Import]Boundary by arityPrecedence the application is last

What this is and why

The problem. Some code wants to be extracted and reused: billing, authentication, an admin panel, a set of entities. Composer handles that — but an ordinary dependency is invisible to the framework. Its controllers do not become routes, its #[Bean]s never reach the container, its #[Scheduled] methods never run: nothing told the framework to look in that directory.

The solution. The application names the package explicitly, and from then on its code is scanned alongside your own.

bootstrap.php
use Flytachi\Winter\Kernel\App\Attribute\Import;

#[Import('acme/billing', '/billing')]   // scanned + controllers mount under /billing
#[Import('acme/toolkit')]               // scanned, mounts no routes
#[EnableWeb]
final class Application extends WinterApplication {  }

Why by name, and not everything

The scanner does not merely read files — it require_onces every file in which it found a class declaration. Walking all of vendor/ would mean executing arbitrary code from every dependency at boot.

So wiring is by name. Spring’s @ComponentScan requires explicit packages for the same reason.

The principle

What a package may and may not do is decided by arity — by how many of a thing the system can hold:

What adds up, adds up. What there is one of belongs to the application.

There can be many routes, beans, log channels, CORS rules, health indicators, migrations and tasks — a package may contribute. There is one server and one set of processes — that is the application’s.

The application applies last.

Where contributions overwrite rather than add up, the application runs after every package and therefore wins. Previously the outcome came down to whose file the scanner happened to reach first.


Reference

#[Import]

php
#[Import('acme/billing', '/billing')]
#[Import('acme/toolkit')]
#[Import('acme/optional', required: false)]
Argument Type Default Purpose
$package string Composer package name
$prefix ?string null URL prefix for its controllers; null mounts no routes. Empty and '/' are refused
$required bool true Fail when the package is not installed

The attribute is repeatable and declared on the application class. Declaration order is the order of application.

The prefix is optional

A package of services, commands and entities should not have to invent a URL:

php
#[Import('acme/toolkit')]   // code in the scan, no routes

“Scan this package” and “mount its routes here” are two independent decisions, and not everyone needs the second.

An empty prefix and '/' will not do: both collapse to the root, and the route comes out as //users — a path that matches no request. That used to mount silently; now it is refused at boot:

text
Plugin 'acme/billing': prefix '/' is not a mount point — every route would start
with '//' and never match a request. Pass a real prefix such as '/billing',
or omit it to import the package without mounting its routes.

There are two ways, and both are explicit: a real prefix, or no argument at all.

An optional package

php
#[Import('acme/analytics', '/analytics', required: false)]

Package missing — the import is skipped. Package present — it behaves like any other. For functionality that is not installed everywhere.

What appears in the log

Every import announces itself at startup — otherwise “did that package actually get wired in?” had to be answered by reading bootstrap.php:

text
[NOTIC] -sys- (Application): Package acme/billing imported — routes mounted under /billing.
[NOTIC] -sys- (Application): Package acme/toolkit imported — no routes mounted.
[WARN ] -sys- (Application): Optional package acme/analytics is not installed — import skipped.

A skipped optional package is a warning rather than a notice: required: false means “carry on without it”, and carrying on used to look exactly like having it — the same quiet start, minus a feature nobody mentioned.

The messages are emitted after the application’s logging configuration has been applied, so their level and channel are yours: raise the threshold to warning and the import notices go away, while the warning about the missing package stays.

A contribution with nowhere to go

A package can bring something the application has not switched on. Nothing breaks — it simply never happens, and the search for why starts in the package. So it is said out loud:

text
[WARN ] Package acme/billing brings 7 controller(s) but was imported without a prefix,
      so none of its routes are mounted. Pass a prefix to #[Import] to mount them.
[WARN ] Package acme/billing brings 7 controller(s), but the application declares no
      #[EnableWeb], so nothing is served.
[WARN ] Package acme/notify brings 3 #[Scheduled] method(s), but the application declares
      no #[EnableScheduler], so none of them run.

The decision stays yours — the framework switches nothing on by itself, it only names the missing switch. Add the #[Enable*] you want and the warnings go away.

The counting rides the same scan pass the package gets anyway: there is no extra walk of the filesystem, and over 182 classes the two checks take 0.03 and 0.15 ms.

What ends up in the scan

Not the package’s whole directory, but what the package declared about itself — the autoload.psr-4 of its composer.json:

vendor/acme/billing/composer.json
{ "autoload": { "psr-4": { "Acme\\Billing\\": "src/" } } }

src/ is scanned, and only it.

Why the directory is not guessed

It used to be a guess: src/ when present, the package’s whole root otherwise. Both branches broke on a package laid out any other way — one keeping its code in main/, say: the router skipped it silently, and the boot scan read its root whole.

Reading the root means require_once for its resources/ templates — and a template under require executes — and for its bootstrap.php if the package is itself an application: that file’s Application class collides with the host’s.

Composer already knows the answer, so the answer comes from there. A package with no autoload.psr-4 has nothing to scan, and #[Import] answers with an explanation rather than doing nothing.

What a package may contribute

What How it composes
Controllers and routes under the package’s prefix; without one, not mounted
Beans, #[Configuration], #[Bean] join the shared container
Classes with #[Singleton], #[Request], #[Transient] as your own
#[Async] methods proxied, when the application declares #[EnableAsync]
WebConfigurer CORS rules add up in the shared registry
LoggingConfigurer channels add up in the shared registry
HealthContributor reach /actuator/health when the actuator is on
Cmd / CmdCustom listed by call script list
#[Scheduled] run when the application declares #[EnableScheduler]
Entities and DbConfig reachable via call db --plugin=<name> and --plugins

What only the application decides

The process manifest

#[EnableWeb], #[EnableScheduler], #[EnableProcess], #[EnableDaemon], #[EnableActuator] and #[EnableAsync] are read from the application class only. A package cannot declare them, and its own — if it happens to be an application itself — are not read.

These attributes decide not how code behaves but the shape of the process: start a server, spawn workers, change call semantics. If they were read from packages, a single composer require would change the deployment topology, and the developer would find out from docker ps.

What happens to the package’s own contents is covered below, under two kinds of switch: some are turned on once and then find everything themselves, packages included; others need you to name a specific class.

Server settings

The bind address and Swoole tuning live in a contract of their own:

php
interface WebConfigurer      // many, additive, open to packages
{
  public function configureCors(CorsRegistry $cors): void;
}

interface ServerConfigurer   // one, application only
{
  public function configureServer(ServerSettings $server, ApplicationArguments $args): void;
}

The split is by arity, not by subject. CORS is a set of rules: two contributions add up and the result is their union. ServerSettings is one object mutated in place: two contributions do not add up — the second overwrites the first.

A ServerConfigurer found inside a package is a boot error naming the class and the package:

text
Only the application may configure the server; found Acme\Billing\Tuning in acme/billing.
A package may implement WebConfigurer (CORS) but not ServerConfigurer.

More than one ServerConfigurer in the application itself is an error too: the server has one owner.

Two kinds of switch

The #[Enable*] attributes come in two kinds, and for packages the difference between them is the main thing to understand.

Turn it on once — it finds everything

#[EnableWeb], #[EnableScheduler], #[EnableActuator], #[EnableAsync]

These switch on a capability, and what that capability then works on is found by the framework itself — in your code and in imported packages alike.

Say you added a package for sending notifications, and somewhere inside it there is a method marked #[Scheduled] that clears a queue once an hour. You do not need to know its name or list it anywhere. It is enough that your application carries #[EnableScheduler]: the scheduler walks your code and the package’s code and picks up everything that is marked.

The reverse holds too. Without #[EnableScheduler], nothing runs — not the package’s tasks and not your own. There is no scheduler in the application at all.

Attribute What it switches on What it finds by itself
#[EnableWeb] the HTTP server controllers — yours, and those of prefixed packages
#[EnableScheduler] the scheduler #[Scheduled] methods everywhere
#[EnableActuator] the /actuator/* endpoints HealthContributor classes everywhere
#[EnableAsync] asynchronous calls #[Async] methods everywhere

Name it — and only what you named runs

#[EnableProcess], #[EnableDaemon]

These take a required argument — a class:

php
#[EnableProcess(AcmeNotificationsWorkerDispatcher::class)]
#[EnableDaemon(MainDaemonImportDaemon::class)]

There is deliberately no automatic discovery here. A background process is not “handle whatever turns up” but “start this worker and keep it alive for as long as the application runs”. The framework cannot guess whether you want a package’s worker, how many of them to start, or in what order — that is a decision about the shape of the deployment, and it belongs to whoever deploys it.

So a process from a package is switched on exactly the way your own is: you name its class. The attribute is repeatable — name as many as you need.

The package cannot start a process by itself. That is a safeguard: otherwise installing an ordinary dependency would quietly add another running process, and you would notice no earlier than the next time you looked at the process list on the server.

Is #[Import] needed for a process from a package?

To start it — no: Composer’s autoloader finds the class by name.

But everything that process uses from inside its own package — beans from #[Configuration], #[Async] methods, its own configurations — appears only with #[Import]. The practical rule is simple: if you use the package, import it; you will still name the process class separately.

Order of application

text
1. packages — in the order they were imported
2. the application — always after

For additive registries this only affects the order of entries. For everything where the last one wins, it guarantees: the application overrides the package, never the reverse.

For example, a package and the application both configure CORS for https://admin.example.com. The application wins — it applies last.

The manifest is not inherited

PHP does not hand attributes up a hierarchy, so a shared base application class does not work:

php
#[EnableWeb]
abstract class BaseApp extends WinterApplication {}

final class Application extends BaseApp {}   // #[EnableWeb] does NOT apply here

Inheritance is deliberately not introduced: the manifest is worth having because one class tells you everything that will start. But staying quiet about it is not an option either — an attribute left on an ancestor is a boot error:

text
#[EnableWeb] is declared on BaseApp, but PHP does not inherit attributes —
it has no effect on Application. Declare it on the application class itself.

Scenarios

WebConfigurer only in the package

Works: its CORS rules join the shared registry. The package does not configure the server — WebConfigurer no longer offers that.

WebConfigurer in both

Both apply and the contributions add up. There is no conflict by construction: CORS is a set of rules, not a single value. Where they overlap, the application wins.

The package has controllers, the application has no #[EnableWeb]

No server starts and no routes are served. The package cannot fix that — and should not: it does not know how it will be deployed. Whether the application wants HTTP is the application’s call.

The package is itself an application

Only what its autoload.psr-4 names is scanned. Its bootstrap.php, storage/ and docker/ stay out of the scan, no name collides, and its own #[Enable*] are not read.

Tooling

bash
php call script list                      # commands, package ones included
php call mapping                          # routes, package ones included
php call db ping --plugins                # databases of every imported package
php call db migrate --plugin=acme/billing # migrations of one package

--plugin takes the Composer package name.

Next