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, commands, and event listeners ahead of time
  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. There’s no required directory or namespace convention to follow; 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), and also produces exit code 1.

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.

kinetis build

php vendor/bin/kinetis build

Removes any existing .kinetis-cache/ and compiles a fresh one — routing and validation plans, commands, and event listeners. 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.

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 executes bootstrap.php, so nothing your bootstrap 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 the application’s services demand. The default (true) executes bootstrap.php before dispatch, exactly what a command that talks to real services wants.

Pass --destroy to remove .kinetis-cache/ without rebuilding it:

vendor/bin/kinetis build --destroy

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, alongside Kinetis’s own documentation resources — 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\MaxBodySizeMiddleware
  4. App\Http\Middleware\RequestIdMiddleware

Method  Path     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

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 — the three Kinetis always wires in first — SecurityHeadersMiddleware, ExceptionHandlerMiddleware, MaxBodySizeMiddleware — then your own explicitly-registered (AppScope::middleware()) and #[AsGlobalMiddleware]-discovered classes, deduplicated (see Middleware). Each route’s own 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, so a route stacking several classes never forces one unreasonably wide line. A route with none 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                    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"
        }
    }
}

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

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:stats --queue=high,default
vendor/bin/kinetis queue:clear --queue=default --force
vendor/bin/kinetis session:gc                     # delete expired sessions

The migrate* commands connect through the same DB_* keys as Persistence; --connection=<name> targets a named DB_{NAME}_* connection block, winning over the MIGRATE_CONNECTION_NAME environment key when both are given. queue:work runs the worker loop against the backend QUEUE_CONNECTION selects, checking queues in the given priority order. Full docs in Migrations, Queue, and Sessions & CSRF.

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, read by kinetis/mcp’s tool and resource discovery) 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.

  • Queuequeue:work, the longest-running command most applications run, and how a package contributes commands of its own.

  • Caching & AOT Compilationkinetis 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.