CLI¶
Kinetis ships one binary, bin/kinetis, installed as vendor/bin/kinetis
once you composer require kinetis/framework. Running it with no
arguments, or an unrecognized one, lists every available command:
php vendor/bin/kinetis
Usage: kinetis <command>
Available commands:
routes:list Displays every discovered route and the full global middleware pipeline
build Compiles routes, MCP tools/resources, commands, and event listeners ahead of time
views:warm Rebuilds the selected view engine cache from the configured view directory
views:clear Empties the selected view engine cache directory
mcp:serve Starts the MCP server over stdio
app:cleanup-sessions Deletes sessions older than 30 days
Writing your own commands¶
Kinetis deliberately doesn’t schedule anything itself — that’s your infrastructure’s job (cron, a Kubernetes CronJob, an EventBridge rule, whatever you already use). What it gives you is a stable, named way to define a command so that infrastructure — or you, by hand — can actually run it:
namespace App\Console;
use Kinetis\Console\Attributes\Command;
use Kinetis\Console\CommandArguments;
final readonly class MaintenanceController
{
#[Command('app:cleanup-sessions', description: 'Deletes sessions older than 30 days')]
public function cleanupSessions(): void
{
// ...
}
#[Command('app:send-report', description: 'Emails a report to a given address')]
public function sendReport(CommandArguments $arguments): int
{
$email = $arguments->get(0);
if ($email === null) {
fwrite(STDERR, "Usage: app:send-report <email>\n");
return 1;
}
// ...
return 0;
}
}
Any class anywhere under one of your own PSR-4 roots is picked up
automatically — App\Console\..., App\Domain\Orders\..., wherever you
keep it. Those are your project’s production autoload.psr-4 roots in
composer.json; autoload-dev.psr-4 (typically your tests/ mapping)
is never scanned, so a command class living only there is not
discovered. There’s no required directory or namespace convention to
follow beyond that; organize commands however the rest of your
application is organized. Discovery reaches a class through its PSR-4
file path, so the standard autoloading layout is what makes a class
findable: one class per file, the file named for the class. A second
class declared inside an existing file isn’t PSR-4-autoloadable, so
discovery never sees it. There’s nothing to register.
Abstract classes, interfaces, traits and enums are skipped: none of them can be instantiated as a command, controller, tool or listener. And an attribute is only read from the class it is written on — see Where attributes are read from below.
Run a command by name:
vendor/bin/kinetis app:cleanup-sessions
vendor/bin/kinetis app:send-report ops@example.com --dry-run
A command method takes either no parameters, or exactly one parameter
typed CommandArguments — everything else it needs (a database pool, a
mailer, …) is constructor-injected, exactly like a controller.
CommandArguments splits whatever followed the command’s own name into
positional values (get(0), get(1), …) and --key=value/bare
--flag options (option('key'), hasOption('flag')).
A command’s own return value becomes the process’s exit code — an int
is used directly; void/null means success (0). This is the actual
signal your scheduler reads to decide whether to alert or retry, so a
command that can fail should say so with a non-zero return rather than
only logging the problem. An uncaught exception is caught once, logged
through whatever Psr\Log\LoggerInterface you’ve registered (see
Logging), dispatches Kinetis\Console\Events\CommandFailed (see
Events), and also produces exit code 1. A failure while
dispatching CommandFailed — resolving the dispatcher or running a
listener — is logged the same way and changes neither the exit code nor
the disposal below.
That exit code is retained even when the disposal that follows the
command fails — the command’s own RequestScope first, then the
application scope. See Appendix: Container Lifecycle’s own general explanation of why
a finally-based dispose is unsafe here. bin/kinetis disposes both
outside any finally that could still replace an already-decided exit
code, each contained separately so a failing request-scope disposal
still leaves the application scope disposed, and defines two cases
explicitly:
The command already threw, or returned a deliberate non-zero exit code. That outcome is exactly what gets returned; a disposal failure on top of it is logged separately (through
AppScope’s own logger, not the now-disposed scope) and never changes the exit code.The command completed successfully with exit code
0, and only disposal then fails. There is nothing else signaling a problem, but a plain0would now be misleading, sobin/kinetisreports exit code70—EX_SOFTWAREfrom BSDsysexits.h(“an internal software error has been detected”), distinct from the1a genuine command failure produces. Either way,bin/kinetisnever fatals uncaught outside the command boundary over a cleanup failure alone.
Both rules apply to the application scope’s disposal too, which runs
after the request scope’s and closes whatever a package opened once for
this process — the database link kinetis/database-bridge built, say.
Its failure is written to STDERR rather than logged: the scope is
already disposed by then, so resolving a logger from it would itself be
refused.
That last-resort line is fixed text naming the command and the class of what was thrown, never the throwable’s own message:
Application disposal failed after command "queue:work" finished: RuntimeException. Its message is withheld — the application logger is released by disposal, so this last-resort line has no redaction policy to route it through.
Disposing the scope releases the services it retained, which runs their
destructors, and a destructor’s message is whatever that service put in
it — a connection error naming a broker URI with its credentials, for
one. Every earlier path reports a disposal failure through the
configured LoggerInterface, which owns the redaction and transport
that message needs; this one has no logger left, so it names what failed
and leaves the detail to what the service logged while it was still
alive. The class plus the command is what locates it there.
MCP tools and resources (see Model Context Protocol (MCP)) and HTTP routes (see Routing & Validation) work the same way — discovered anywhere under your own PSR-4 roots, with no directory convention required.
Long-running commands and signals¶
A command’s int return is the only thing Kinetis carries out of a
command; there is no signal, cancellation or shutdown abstraction behind
it. A command that runs long enough for a deploy to reach it — a
backfill, a drain, a poll loop — owns that itself. queue:work is the
one Kinetis ships, and Deploys and restarts
is where the stop signal a supervisor actually sends and the grace
period it allows are settled; both apply to a command you write.
A command that promises graceful SIGTERM/SIGINT handling:
Requires
ext-pcntl. It is CLI-only and the official PHP images do not load it. Without it the process cannot observe a signal at all, and the supervisor’s kill lands wherever the command happens to be.Installs its own handlers —
pcntl_async_signals(true), then onepcntl_signal()per signal it accepts.Sets one flag in the handler and does nothing else. The flag belongs to this invocation — a local variable the handler captures by reference, or a property of a per-invocation object — never a static or a global. I/O, transaction work, logging or disposal inside a handler runs at an arbitrary point inside whatever was already executing.
Reads that flag only at checkpoints it chooses — between items, between batches, after a unit of work has settled — and nowhere else. Under the skeleton’s strict PHPStan level, both documented forms are flow-narrowed when read directly: a by-reference local to whatever it held before the handler could plausibly run, a property read straight off the object to the value its constructor set. Either way a second checkpoint reports
if.alwaysFalse, because the analyzer has no model of asynchronous signal mutation. Only the object form has anywhere to attach a fix: give it a checkpoint method that callspcntl_signal_dispatch()before returning the flag, annotated@phpstan-impure. The dispatch call is what clears the narrowing — a observable side effect, which the annotation alone on a plain getter does not establish and strict purity analysis rejects:final class StopRequested { private bool $flag = false; public function handler(): void { $this->flag = true; } /** @phpstan-impure */ public function isSet(): bool { pcntl_signal_dispatch(); return $this->flag; } }
pcntl_signal(SIGTERM, $stop->handler(...))registers it; each checkpoint calls$stop->isSet(). Withpcntl_async_signals(true)already enabled, explicit dispatch is not required for normal delivery here — the call’s purpose is to give the checkpoint method an observable side effect the analyzer must account for, not to make the handler run.
A signal cancels nothing already in flight. An async request or an open transaction runs until it completes or its own deadline expires, so every such wait needs a finite deadline whether or not the command handles signals, and the command documents which outcome an operator gets: the atomic unit in progress completes, or it is left untouched.
When an in-flight operation fails, read the flag before classifying the exit. A request that exhausted its deadline while a stop was already pending is a shutdown, and reporting it as a timeout hides the operator’s signal.
The exit code is the command’s own contract. 130 for an observed
SIGINT and 143 for an observed SIGTERM follow the shell’s
128 + signal convention and are worth adopting when the command says so;
a command that treats a signalled stop as an ordinary completion returns
0. The disposal rules above apply on top of whichever code it returns.
kinetis build¶
php vendor/bin/kinetis build
Compiles a fresh cache — routing and validation plans, commands, and
event listeners — from your project’s own source, and replaces
.kinetis-cache/compiled.php with it. The published artifact is an
output of this command, never an input to it, and a previously-published
one stays exactly as it was until the new one has been reconstructed
into the live objects a boot needs, written whole, and read back intact.
A failed compile, a section that will not reconstruct, or a failed write
never takes down a cache a previous build already produced, and never
reports success. Run this as part of your deploy pipeline to pre-warm
the cache before real traffic arrives — see Caching & AOT Compilation for exactly
what gets written and how publishing works.
Build into the artifact or image you deploy, before any worker starts. This command runs in its own CLI process, which is what decides where it belongs in a deploy — see “Deploying a rebuilt artifact” in Caching & AOT Compilation for the contract, and for what a live shared deployment needs on top of it.
Always runs, regardless of APP_ENV — safe to run from a CI runner, a
laptop, or any machine that hasn’t set that variable. It also runs
without the application’s own configuration: build never runs the
bootstrap chain — neither an installed package’s own
PackageBootstrapInterface::register() nor your own bootstrap.php —
so nothing either of them registers, a database connection factory
demanding DB_PASSWORD, a client demanding API keys, can make it fail.
A CI pipeline pre-warms the cache with no production secrets present.
Your own commands get the same choice: #[Command] accepts
bootstrap: false for any command that only operates on the project’s
static shape and shouldn’t require the configuration a package’s or
your own bootstrap registers. The default (true) runs every installed
package’s PackageBootstrapInterface::register() first, then your own
bootstrap.php last, before dispatch — exactly what a command that
talks to real services wants.
kinetis mcp:serve¶
php vendor/bin/kinetis mcp:serve
Contributed by kinetis/mcp — present exactly when that package is
installed, like queue:work or migrate. Starts the MCP server over
stdio — one JSON-RPC message per line in, one per line out — the way
Claude Desktop, Cursor, and most local MCP clients launch a server as a
subprocess. Your own tools and resources are included automatically —
see Model Context Protocol (MCP).
kinetis routes:list¶
php vendor/bin/kinetis routes:list
Global middleware (outermost to innermost):
1. Kinetis\Http\Middleware\SecurityHeadersMiddleware
2. Kinetis\Http\Middleware\ExceptionHandlerMiddleware
3. Kinetis\Http\Middleware\RequestBodyMiddleware
4. App\Http\Middleware\RequestIdMiddleware
Method Path Where Status Controller Middleware
------ --------------------- ------------- ------ --------------------------------- ---------------------------------------
GET /orders — 200 App\Http\OrderController::index App\Http\Middleware\AuthMiddleware
POST /orders — 201 App\Http\OrderController::store App\Http\Middleware\AuthMiddleware ->
App\Http\Middleware\RateLimitMiddleware
GET /orders/{year}/{slug} year: \d{4} 200 App\Http\OrderController::archive App\Http\Middleware\AuthMiddleware
slug: [a-z-]+
A read-only display tool — it never touches .kinetis-cache/ and never
writes anything. Every invocation is a fresh, live discovery of your
current source, regardless of APP_ENV, so it always reflects your code
exactly as it stands right now, not whatever a stale compiled cache
happens to hold.
The global middleware section lists the exact order requests run in:
SecurityHeadersMiddleware, a registered CorsMiddleware,
ExceptionHandlerMiddleware, RequestBodyMiddleware, then every other
explicitly registered (AppScope::middleware()) and
#[AsGlobalMiddleware]-discovered class, deduplicated. CORS is therefore
listed second when configured, directly after SecurityHeadersMiddleware (see
Global order).
Each route’s Where column lists its route constraints, one
name: fragment line per constrained placeholder in the order the
placeholders appear in the path, whatever order the attribute declared
them in (see Route
constraints). Its
Middleware column shows its #[Middleware] list in the same
class-level-then-method-level order it actually runs in, one middleware
per line — every line but the last ends with -> to mark it continues on
the next. A route spans as many lines as its longest list, so neither
column forces one unreasonably wide line. A column with nothing to list
shows —.
A @name middleware-group reference (see Middleware) is shown
already expanded into the classes that actually run, each annotated with
the group it came from:
Method Path Where Status Controller Middleware
------ ------------------- ----- ------ -------------------------------- ---------------------------------------------------
GET /orders/{id}/refund — 200 App\Http\OrderController::refund App\Http\Middleware\AuthMiddleware (@admin) ->
App\Http\Middleware\RequireAdminMiddleware (@admin)
Package-provided commands and services¶
Installed packages plug into the same discovery your own application
uses. A package declares its participation in its composer.json under
extra.kinetis:
{
"extra": {
"kinetis": {
"scan": "Acme\\Reports\\Console\\",
"bootstrap": "Acme\\Reports\\PackageBootstrap",
"discovery": "Acme\\Reports\\ReportRegistry"
}
}
}
All three keys are optional. scan is a comma-separated list of PSR-4
namespace prefixes (each must sit at or below one of the package’s own
declared PSR-4 roots); every class under them joins the same
attribute-driven discovery as your application’s own code — #[Command]
methods become kinetis commands, and #[Get]/#[Listener]/
#[AsGlobalMiddleware] classes register the same way — #[McpTool] and
#[McpResource] too, once kinetis/mcp is installed.
bootstrap names a class implementing
Kinetis\Container\PackageBootstrapInterface; its register(AppScope $app, Config $config) runs before your application’s own
bootstrap.php, so a package can bind its services from configuration
alone, and anything your bootstrap.php registers afterward wins over a
package’s binding for the same id. A package bootstrap should stay inert
when its configuration is absent — wiring, not side effects.
A package bootstrap that needs the application’s project root resolves
Kinetis\Runtime\ProjectRoot from $app and reads its path: the
exact root the entry point resolved, bound before any bootstrap runs.
Commands receive the same binding by constructor injection, including
#[Command(bootstrap: false)] commands, which skip the bootstrap chain.
discovery names a class implementing
Kinetis\Cache\CacheableDiscoveryInterface — a package’s own
compile-time-discoverable data, folded into the shared AOT cache
alongside routes/commands/events (see Caching & AOT Compilation). The package
supplies only compile(DiscoveryContext $context): array (live
discovery, reduced to plain data) and fromArray(array $data): static
(reconstruction); the framework owns everything else — compiling each
section once per build or development boot, writing/loading the cache,
and binding the reconstructed instance into the container before any
PackageBootstrapInterface::register() call runs, package bootstraps
included. A package’s own bootstrap never touches this data at all.
Kinetis\Cache\DiscoveryContext belongs to one discovery operation —
a development HTTP or CLI boot, a TestApplication boot,
kinetis build, a production fallback compile, or routes:list — and
is dropped when that operation ends. $context->projectRoot is the project root.
projectClasses(), frameworkClasses() and packageClasses() return
the classes under the project’s PSR-4 roots, one framework namespace
segment, and installed packages’ scan roots; each root is walked once
per operation, however many discoverers and sections ask for it. A
section that consumes another section’s data reads it with
compiled():
namespace Acme\Reports;
use Acme\Catalog\ProductCatalog;
use Kinetis\Cache\CacheableDiscoveryInterface;
use Kinetis\Cache\DiscoveryContext;
use Kinetis\Cache\Exception\InvalidCacheArtifactException;
final class ReportIndex implements CacheableDiscoveryInterface
{
private function __construct(public readonly array $products) {}
public static function compile(DiscoveryContext $context): array
{
// acme/catalog's own discovery section, as compiled data.
$catalog = $context->compiled(ProductCatalog::class);
return ['products' => array_keys($catalog['products'])];
}
public static function fromArray(array $data): static
{
if (!is_array($data['products'] ?? null)) {
throw InvalidCacheArtifactException::malformedEntry('ReportIndex', 'no products list');
}
return new self($data['products']);
}
}
compiled() compiles the named section first if nothing has read it
yet, and returns the same plain array to every later reader, so a
dependency compiles once and before its consumer finishes, whatever
order Composer installed them in. The compiled cache still lists
sections in Composer’s order. The named section must be the discovery
class of an installed package, and two sections must not read each
other; either mistake fails the compile — kinetis build, or a
development boot — with an exception naming the sections involved. A
consumer receives the compiled array, never the upstream instance.
Discovery reads these keys from Composer’s installed-package record,
vendor/composer/installed.json, not from the package’s own
composer.json. That record is written when Composer installs or
updates the package, so after changing extra.kinetis in a package
installed from a path repository, run a scoped update naming that
package — acme/reports stands in for its Composer name:
composer update acme/reports
Installing a package is what opts it in — there is no separate allow-list. If you install a package, you trust what it registers, the same trust already extended to any code Composer autoloads.
kinetis/migrations, kinetis/queue, and kinetis/session ship their
commands this way:
vendor/bin/kinetis migrate # apply pending migrations
vendor/bin/kinetis migrate:rollback # roll back the last one
vendor/bin/kinetis migrate:status # applied/pending listing
vendor/bin/kinetis migrate:make "create users" # scaffold a migration file
vendor/bin/kinetis queue:work --queue=high,default
vendor/bin/kinetis queue:work --connection=ledger # a named queue connection
vendor/bin/kinetis queue:stats --queue=high,default
vendor/bin/kinetis queue:clear --queue=default --force
vendor/bin/kinetis session:gc # delete expired sessions
vendor/bin/kinetis views:warm # compile every configured view
vendor/bin/kinetis views:clear # empty the selected view cache
The migrate* commands connect through the same DB_* keys as
Database. The files in migrations/ belong to the default
connection, and each directory migrations/<name>/ to the named
DB_{NAME}_* connection. migrate and migrate:status cover every
connection, default first, one database at a time.
--connection=<name> narrows migrate, migrate:status and
migrate:rollback to one connection, winning over
the MIGRATE_CONNECTION_NAME environment key when both are given;
migrate:rollback requires one of the two once connection directories
exist, and migrate:make --connection=<name> writes to that
connection’s directory. queue:work runs the worker loop against the
bound QueueInterface, checking queues in the given priority order;
queue:work --connection=<name> runs that named queue connection
instead, built from its own QUEUE_{NAME}_CONNECTION selector, or
QUEUE_CONNECTION for default. queue:clear needs a backend that can
clear, which sqs is not — it names the backend and exits 1 there
instead. Full docs in Migrations, Queue, and
Sessions & CSRF. The view commands come from kinetis/views, run the normal
application bootstrap to resolve its configured adapter, and delegate the
vendor-specific work to that adapter; see Views.
Development vs. production¶
In development, commands are discovered fresh on every invocation, so a
newly-added #[Command] method is picked up immediately. In production
(APP_ENV=production, the default when unset), the binary loads its
command list from the compiled cache kinetis build produces, compiling
one automatically on the first invocation if none exists yet.
Where attributes are read from¶
An attribute applies to the class it is written on, and nowhere else.
This is PHP’s own rule for class attributes — a subclass never inherits a
parent’s — and Kinetis applies the same rule to methods, which PHP leaves
open: ReflectionClass::getMethods() returns a parent’s methods flattened
in with a class’s own, each still carrying whatever attributes were
written on it further up.
So a routed method, a #[Command], an #[McpTool] or a #[Listener]
must be declared by the class being registered. One inherited from a
parent is rejected at registration rather than silently registered
against a class whose own attributes would then go unread:
abstract class BaseController
{
#[Get('/health')] // belongs to BaseController
public function health(): array { ... }
}
#[Hidden] // would never be consulted
final class InternalController extends BaseController {}
Share a routed method through a trait instead. PHP reports a trait method’s declaring class as the class that uses the trait, so it counts as that class’s own and every attribute on that class applies normally:
trait HealthRoute
{
#[Get('/health')]
public function health(): array { ... }
}
#[Hidden]
final class InternalController
{
use HealthRoute; // registered, and hidden
}
Attributes written on the trait declaration itself are ignored — only its methods carry through. Extending a base class for ordinary shared behaviour stays perfectly legal; only an inherited method that carries an attribute is an error.
Restricting discovery¶
Once you’re relying on the compiled cache in production, scanning the
whole application on every request is not the relevant cost — the
scan only ever runs live in development, or once to build the cache.
Even so, for a large enough codebase, that development-time scan can be
worth bounding. COMMAND_DISCOVERY_PATHS (and its siblings
ROUTE_DISCOVERY_PATHS/MIDDLEWARE_DISCOVERY_PATHS/
LISTENER_DISCOVERY_PATHS for HTTP, global-middleware and event-listener
discovery — see Middleware/Events for the last two — plus
MCP_DISCOVERY_PATHS and BROADCAST_CHANNEL_DISCOVERY_PATHS, read by
kinetis/mcp and kinetis/broadcasting; Configuration lists all six)
restricts the scan to one or more comma-separated sub-paths, relative to
each PSR-4 base directory your composer.json declares:
COMMAND_DISCOVERY_PATHS=Console
With "App\\": "src/" in your autoload.psr-4, this restricts command
discovery to src/Console/ — a class anywhere else under src/ is no
longer scanned. Most small and medium applications never need this; it’s
meant for a team that has measured a real, unacceptable scan cost and
wants a deliberate, git-tracked restriction instead of relying on every
developer to remember one. Kinetis’s own built-in commands, tools,
resources, middleware, and listeners (under Kinetis\Console/Kinetis\Mcp/
Kinetis\Http/Kinetis\Events) are unaffected by any of these five
variables either way — they’re always found in their own fixed location,
never subject to your application’s own discovery scope. The same goes
for anything an installed package registers through extra.kinetis (see
above): a package’s own declared scan roots are read as given, outside
these variables’ reach.
See also¶
Events — listeners are discovered the same way commands are, and a command is a common place to dispatch one from.
Queue —
queue:work, the longest-running command most applications run, and how a package contributes commands of its own.Caching & AOT Compilation —
kinetis build, which compiles the discovery this page describes into a cache production reads instead.Model Context Protocol (MCP) —
mcp:serve, and tools discovered by the same scan.