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 plain 0 would now be misleading, so bin/kinetis reports exit code 70 — EX_SOFTWARE from BSD sysexits.h (“an internal software error has been detected”), distinct from the 1 a genuine command failure produces. Either way, bin/kinetis never 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 one pcntl_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 calls pcntl_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(). With pcntl_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:

A package’s composer.json
{
    "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:

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