Skip to content

The adapter contract

What every framework adapter provides, in the framework’s idiom. Each item is an acceptance criterion of the adapter’s work package.

3.1 Boot: one Polaris per process from the framework’s configuration

Section titled “3.1 Boot: one Polaris per process from the framework’s configuration”

Polaris::create(new Config(...)) runs once, lazily, and the result is a shared service (Polaris\Polaris, Polaris\Wiring\Graph, Polaris\Psr15\Pipeline) in the framework’s container. The configuration maps to Config as follows; every port accepts null (core default), a service id or class name (resolved from the container) or an object instance (used as is), so a host can build any port in code:

Config keyConfig fieldSource
secretssecretsapp_key, jwt_private_key, jwt_public_key, jwt_kid, jwt_previous_public_key, jwt_previous_kid; each value may instead come from <key>_file (a PEM path). Defaults read the environment names core reads (APP_KEY, AUTH_JWT_*, plus *_FILE).
authauthAuthConfig::fromArray(), the docs/auth/configuration.md §1 keys; defaults issuer from AUTH_ISSUER, audience from AUTH_AUDIENCE, the two flags from AUTH_ACCESS_TOKEN_DENYLIST and AUTH_PASSWORD_BREACH_CHECK, as EnvironmentConfig::auth() does
rate_limitsrateLimitsRateLimitConfig::fromArray()
databasedatabasenull: PdoAdapter over the framework’s default connection; a connection name: that connection; an object: a DatabaseAdapter
cachecachenull: the framework’s default cache (PSR-16); a store name
logloggernull: the framework’s default logger (PSR-3); a channel name
mailer, smsmailer, smsnull or log: core’s LogOtpMailer / LogSmsSender on the framework logger; mail: the framework’s mailer bridge (§3.5); a service id, class or object
breach_check, clock, encrypter, metrics, totp, qr_codes, rate_storethe same-named fieldsnull or a service
path_prefixpathPrefixthe mount point of the routes, default /
manifest_directorymanifestDirectorydefault: the package’s api/

The dispatcher is always the framework’s (through the bridge where needed); Polaris::listeners() (audit log, notifications, metrics) are subscribed to it at boot. A host that passes its own PSR-14 dispatcher as dispatcher gets it as is, with the listeners subscribed only if it can subscribe them (the bridge can; a foreign dispatcher is the host’s responsibility, documented).

3.2 Routes: every manifest route mounted under path_prefix

Section titled “3.2 Routes: every manifest route mounted under path_prefix”

The adapter registers the manifest routes under the prefix, one framework route per endpoint where the framework keeps a route table (so its route listing shows them and nothing shadows the application’s own routes when the prefix is /), all served by Polaris\Psr15\Pipeline::handle(): the framework request converted to PSR-7 (through symfony/psr-http-message-bridge where the framework is HttpFoundation-based; natively in Yii), the PSR-7 response converted back. The framework’s own request middleware (sessions, cookies, CSRF, its rate limiter) is not applied to Polaris routes. The client IP the framework resolved (trusted proxies) is passed as Attributes::IP_ADDRESS before the pipeline runs, so ClientContextMiddleware and the rate-limit keys honour the host’s proxy configuration. A wrong method on a Polaris route answers Polaris’s 405 envelope; an unknown path is the application’s 404.

3.3 Authentication for the host’s own routes

Section titled “3.3 Authentication for the host’s own routes”

A guard (Laravel), an authenticator (Symfony) or an authentication method (Yii) that verifies Authorization: Bearer with Graph::tokenFactory(), loads the user through Graph::users(), and exposes a Polaris\<Fw>\Auth\PolarisUser carrying the Polaris\Model\User and the verified TokenInterface (claims: organization, roles, permissions) in the framework’s identity type. Its 401 for the host’s routes is the framework’s, not Polaris’s envelope (documented: Polaris routes answer Polaris’s 401; the host’s routes answer the host’s). No credentials login through it: login is the endpoint’s job.

3.4 Schema and seed through the framework’s migration tool

Section titled “3.4 Schema and seed through the framework’s migration tool”

A console command writes the framework’s migration artefact (Laravel: a migration file; Symfony and Yii: a polaris:schema:create / polaris:schema:drop command pair, since neither framework has one migration tool) whose up executes SqlSchema::createAll() for the connection’s dialect through PdoAdapter and seeds the permission catalog and the system roles (PermissionCatalogSeeder), and whose down executes SqlSchema::dropAll(). No translation of the schema into the framework’s schema builder: the SQL exporter is the single source and schema:diff proves parity (the demo’s setup runs it).

mailer: mail binds the framework’s mailer to OtpMailerInterface: the template name selects a plain-text template shipped by the adapter (verify_email, password_reset, org_invite, otp_code, account_locked, password_changed, mfa_enrolled, mfa_factor_removed, recovery_code_used, recovery_codes_regenerated), rendered with the context, with a subject per template; templates are publishable/overridable the framework’s way. The default stays log, as in core: a host chooses delivery explicitly.

The four polaris/cli commands are registered in the framework’s console as polaris:schema:export, polaris:schema:diff, polaris:manifest, polaris:doctor, using the framework’s connection and the adapter’s Secrets/AuthConfig instead of POLARIS_DSN and the environment (the CLI commands gain optional providers for these; --dsn still overrides). Plus the adapter’s own polaris:install (§3.4, and publishing the configuration file where the framework has that notion).

3.7 The proof: the functional suite through the framework’s kernel

Section titled “3.7 The proof: the functional suite through the framework’s kernel”

FunctionalTestCase (core’s tests) gets a harness seam:

namespace Polaris\Tests\Functional;
interface Harness
{
/** Boots the host from this Config (the test's adapter, mailer, sms, dispatcher, secrets, auth). */
public static function create(Config $config): static;
public function graph(): Graph;
public function handle(ServerRequestInterface $request): ResponseInterface;
/** @return list<string> lower-case names of the transport headers the host adds to every response */
public static function transportHeaders(): array;
}

PipelineHarness (core) wraps Polaris::create() + Pipeline, transportHeaders() empty: the task 1 run, unchanged. FunctionalTestCase::boot() instantiates the class named by POLARIS_HARNESS (default PipelineHarness); Fixture/Normalizer ignore the harness’s transport headers on top of the volatile ones. Each adapter ships Polaris\<Fw>\Tests\Harness under packages/<fw>/tests (root autoload-dev), which boots a real application with the adapter installed, hands the test’s Config objects to the adapter’s configuration (§3.1 accepts instances), serialises the PSR-7 request to the wire form the framework parses (a JSON body is bytes, not a parsed array), pushes it through the framework’s HTTP kernel, and converts the response back. CI runs, per adapter, on the same PostgreSQL service: POLARIS_HARNESS=<class> vendor/bin/phpunit --testsuite functional (the functional suite is packages/core/tests/Functional plus tests/Contract; the default run excludes it to avoid running those tests twice). ContractCoverageTest keeps asserting the 52 routes and more than 1,000 steps regardless of the harness. The 184 fixtures pass or the build fails; the ignored transport headers are logged in decisions.md.

The adapter’s own behaviour (configuration mapping, the guard, the event and mail bridges, the install command) has unit tests under packages/<fw>/tests, in the default composer qa run.

examples/<fw>: a minimal host on the adapter with SQLite, a file “mailbox” for the walkthrough, bin/setup (env, RS256 keys, polaris:install, the migration, schema:diff clean, polaris:doctor ok) and bin/walkthrough.sh calling examples/walkthrough.sh. A CI job runs composer install, bin/setup, bin/walkthrough.sh on PHP 8.3, as the Slim job does. The demo README shows the host’s integration in full; it should fit on one screen.

Apply the framework’s session, cookie or CSRF middleware to Polaris routes; alter status, body or Polaris headers; re-implement any psr15 middleware; carry defaults that differ from core’s; read secrets from a config file committed to the demo (they come from the environment or files the setup generates); depend on the framework’s ORM.


packages/laravel, Polaris\Laravel\, laravel/framework ^13.0.

  • PolarisServiceProvider (auto-discovered through extra.laravel.providers): merges config/polaris.php (publishable, tag polaris-config), registers Polaris, Graph and Pipeline as singletons built by PolarisFactory from config('polaris') per §3.1 (database null or a connection name → PdoAdapter over DB::connection()->getPdo(); cacheCache::store(), PSR-16; logLog::channel()), registers the routes in boot() (one named route per manifest endpoint, polaris.auth.login and so on, all to PolarisController, under config('polaris.path_prefix') with the middleware list from config('polaris.middleware'), default none), extends Auth with the polaris guard driver, registers the artisan commands, subscribes Polaris::listeners() to the event bridge, loads the mail views.
  • PolarisController: Illuminate\Http\Request → PSR-7 through PsrHttpFactory (nyholm), Attributes::IP_ADDRESS = $request->ip(), Pipeline::handle(), PSR-7 → Illuminate\Http\Response through HttpFoundationFactory.
  • Events\Dispatcher (PSR-14): runs the Polaris listeners, then Illuminate\Contracts\Events\Dispatcher::dispatch($event), so Event::listen(UserRegistered::class, ...) works in the application.
  • Auth\PolarisGuard (Illuminate\Contracts\Auth\Guard, built like the framework’s TokenGuard with the current request refreshed) and Auth\PolarisUser (Authenticatable over Polaris\Model\User plus the token). auth:polaris is then the framework’s middleware. Configuration: auth.guards.polaris = ['driver' => 'polaris'].
  • Mail\OtpMailer: Illuminate\Contracts\Mail\Mailer with plain-text Blade views polaris::mail.<template> (namespace polaris, publishable, tag polaris-views).
  • Console\InstallCommand (polaris:install): publishes the config and writes database/migrations/<timestamp>_create_polaris_tables.php, whose up()/down() call Polaris\Laravel\Schema\PolarisSchema::create() / drop() (§3.4; the migration runs outside the migrator’s transaction because PdoAdapter opens its own). The four CLI commands are registered renamed with the application’s connection, Secrets and AuthConfig.
  • Secrets: config/polaris.php reads POLARIS_APP_KEY, falling back to Laravel’s APP_KEY (its base64: value is at least 32 bytes; every Polaris key is HKDF-derived from it under a distinct context, so sharing the master key with Laravel’s encrypter is acceptable and documented), and AUTH_JWT_PRIVATE_KEY[_FILE], AUTH_JWT_PUBLIC_KEY[_FILE], AUTH_JWT_KID, the previous-key pair, AUTH_ISSUER, AUTH_AUDIENCE.
  • Tests\Harness: boots an application with orchestra/testbench-core (Orchestra\Testbench\Foundation\Application::create() with the provider), sets config('polaris') to the test’s instances (database, mailer, sms, dispatcher, secrets, auth) and cache to the array store, converts the PSR-7 request to an Illuminate\Http\Request (JSON body serialised to bytes), runs Illuminate\Contracts\Http\Kernel::handle(), converts the response back with PsrHttpFactory. transportHeaders() lists what HttpFoundation adds (cache-control at least; the run decides, decisions.md records).
  • examples/laravel: composer.json (framework, adapter through the path repository), artisan, bootstrap/app.php (Application::configure()), public/index.php, config/polaris.php, config/auth.php (the guard), config/database.php (SQLite at database/polaris.sqlite), .env.example, src/FileMailer.php (the JSON-lines mailbox), bin/setup, bin/walkthrough.sh, README.
  • CI: the qa matrix gains the step POLARIS_HARNESS=Polaris\Laravel\Tests\Harness vendor/bin/phpunit --testsuite functional; a demo-laravel job mirrors the Slim one.

packages/symfony, Polaris\Symfony\, symfony/framework-bundle ^7.4 || ^8.0.

  • PolarisBundle (AbstractBundle): configure() declares the tree of §3.1 under polaris: (secrets, auth, rate_limits, database: { dsn, user, password } or database: { connection: <service id> } accepting a PDO or a Doctrine DBAL Connection (getNativeConnection()), cache (a PSR-16 service id, default a Psr16Cache over cache.app), logger, mailer, sms, the ports, path_prefix); loadExtension() registers Polaris, Graph, Pipeline (nyholm PSR-17 + the bridge), PolarisController, the route loader, the authenticator, the event subscriber, the console commands.
  • Routing: a route loader of type polaris (config/routes.yaml: polaris: { resource: ., type: polaris }) yielding one named route per manifest endpoint (polaris.auth.login, …) under polaris.path_prefix (the bundle’s setting, so the pipeline strips the same prefix), all to PolarisController (Request → PSR-7 → Pipeline::handle()Response; Attributes::IP_ADDRESS = $request->getClientIp()).
  • Events: symfony/event-dispatcher is PSR-14 but dispatches by exact class; PolarisEventSubscriber subscribes Polaris::listeners() to every class in Polaris\Event (listed from the directory at compile time), so #[AsEventListener(UserRegistered::class)] works in the application.
  • Security: Security\PolarisAuthenticator (AbstractAuthenticator, also the firewall’s entry point: 401 in Polaris’s envelope with WWW-Authenticate: Bearer) for security.firewalls.<name>.custom_authenticators, no user provider needed; Security\PolarisUser (UserInterface) with the token; roles ROLE_USER plus the Polaris roles as ROLE_POLARIS_<ROLE> (a mapping, not a policy).
  • Console: polaris:schema:create, polaris:schema:drop, and the four CLI commands renamed, tagged console.command.
  • Tests\Harness: a Kernel with MicroKernelTrait, FrameworkBundle + PolarisBundle, configuration in code with the test’s instances (the cache included: Symfony resets its array adapter between requests) registered as synthetic services the bundle’s service: keys point at; $kernel->handle(); the bridge for both directions.
  • examples/symfony: src/Kernel.php, config/{bundles.php,packages/{framework,security,polaris}.yaml,routes.yaml,services.yaml}, public/index.php, bin/console, SQLite DSN, a /app/me route behind the authenticator, bin/setup, bin/walkthrough.sh, README.

packages/yii, Polaris\Yii\, Yii 3 (yiisoft/yii-http ^1.1, yiisoft/router ^4.0, yiisoft/di, yiisoft/config, yiisoft/auth, yiisoft/yii-console).

  • Config plugin (extra.config-plugin): config/di.php (the Polaris, Graph, Pipeline definitions from params['polaris'], §3.1; database is a DSN, or the application’s PDO, yiisoft/db connection (getPDO()) or DatabaseAdapter definition), config/params.php (the defaults and the yiisoft/yii-console command map), config/routes.php (one named route per manifest endpoint under the prefix, polaris.auth.login and so on, whose action is PolarisController::handle, the whole pipeline; PSR-15 native, no bridge), config/di-console.php (the commands, renamed through setName()).
  • Events: yiisoft/event-dispatcher is PSR-14 but dispatches by exact class; the plugin’s config/events-web.php and events-console.php map every Polaris\Event class to PolarisListener, the Yii way.
  • Auth: Auth\PolarisAuthenticationMethod (Yiisoft\Auth\AuthenticationMethodInterface, bearer, WWW-Authenticate: Bearer challenge) returning Auth\PolarisIdentity (IdentityInterface with the user and the token); the polaris/authentication definition is yiisoft/auth’s Authentication middleware with it and a 401 in Polaris’s envelope, for the host’s routes.
  • Console: the six CLI commands (schema:create and schema:drop included) as polaris:*, through the yiisoft/yii-console command map.
  • Tests\Harness: a Yiisoft\Di\Container built from the plugin’s config files plus what an application provides (PSR-17 factories, the route collection, the dispatcher, the HTTP application on RequestBodyParser and the router) with the test’s instances as definitions; Yiisoft\Yii\Http\Application::handle(); no transport headers.
  • examples/yii: a yiisoft/app-shaped minimal host on the HTTP and console runners: config/{params,di,di-web,routes,events}.php, public/index.php, yii, a /app/me route behind polaris/authentication, bin/setup, bin/walkthrough.sh, README.