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].
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.
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]
#[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:
#[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:
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
#[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:
[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:
[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:
{ "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:
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:
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:
#[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
1. packages — in the order they were imported
2. the application — always afterFor 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:
#[EnableWeb]
abstract class BaseApp extends WinterApplication {}
final class Application extends BaseApp {} // #[EnableWeb] does NOT apply hereInheritance 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:
#[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
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
- Web configuration —
WebConfigurerandServerConfigurer - Dependency injection — how a package’s beans reach the container
- Routing — how the URL is built under a prefix
- Actuator — health indicators from packages