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 key | Config field | Source |
|---|---|---|
secrets | secrets | app_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). |
auth | auth | AuthConfig::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_limits | rateLimits | RateLimitConfig::fromArray() |
database | database | null: PdoAdapter over the framework’s default connection; a connection name: that connection; an object: a DatabaseAdapter |
cache | cache | null: the framework’s default cache (PSR-16); a store name |
log | logger | null: the framework’s default logger (PSR-3); a channel name |
mailer, sms | mailer, sms | null 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_store | the same-named fields | null or a service |
path_prefix | pathPrefix | the mount point of the routes, default / |
manifest_directory | manifestDirectory | default: 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).
3.5 Mail
Section titled “3.5 Mail”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.
3.6 Console
Section titled “3.6 Console”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.
3.8 The demo
Section titled “3.8 The demo”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.
3.9 What an adapter must not do
Section titled “3.9 What an adapter must not do”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.
4. Laravel (WP1)
Section titled “4. Laravel (WP1)”packages/laravel, Polaris\Laravel\, laravel/framework ^13.0.
PolarisServiceProvider(auto-discovered throughextra.laravel.providers): mergesconfig/polaris.php(publishable, tagpolaris-config), registersPolaris,GraphandPipelineas singletons built byPolarisFactoryfromconfig('polaris')per §3.1 (databasenull or a connection name →PdoAdapteroverDB::connection()->getPdo();cache→Cache::store(), PSR-16;log→Log::channel()), registers the routes inboot()(one named route per manifest endpoint,polaris.auth.loginand so on, all toPolarisController, underconfig('polaris.path_prefix')with the middleware list fromconfig('polaris.middleware'), default none), extendsAuthwith thepolarisguard driver, registers the artisan commands, subscribesPolaris::listeners()to the event bridge, loads the mail views.PolarisController:Illuminate\Http\Request→ PSR-7 throughPsrHttpFactory(nyholm),Attributes::IP_ADDRESS=$request->ip(),Pipeline::handle(), PSR-7 →Illuminate\Http\ResponsethroughHttpFoundationFactory.Events\Dispatcher(PSR-14): runs the Polaris listeners, thenIlluminate\Contracts\Events\Dispatcher::dispatch($event), soEvent::listen(UserRegistered::class, ...)works in the application.Auth\PolarisGuard(Illuminate\Contracts\Auth\Guard, built like the framework’sTokenGuardwith the current request refreshed) andAuth\PolarisUser(AuthenticatableoverPolaris\Model\Userplus the token).auth:polarisis then the framework’s middleware. Configuration:auth.guards.polaris = ['driver' => 'polaris'].Mail\OtpMailer:Illuminate\Contracts\Mail\Mailerwith plain-text Blade viewspolaris::mail.<template>(namespacepolaris, publishable, tagpolaris-views).Console\InstallCommand(polaris:install): publishes the config and writesdatabase/migrations/<timestamp>_create_polaris_tables.php, whoseup()/down()callPolaris\Laravel\Schema\PolarisSchema::create()/drop()(§3.4; the migration runs outside the migrator’s transaction becausePdoAdapteropens its own). The four CLI commands are registered renamed with the application’s connection,SecretsandAuthConfig.- Secrets:
config/polaris.phpreadsPOLARIS_APP_KEY, falling back to Laravel’sAPP_KEY(itsbase64: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), andAUTH_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 withorchestra/testbench-core(Orchestra\Testbench\Foundation\Application::create()with the provider), setsconfig('polaris')to the test’s instances (database,mailer,sms,dispatcher,secrets,auth) andcacheto thearraystore, converts the PSR-7 request to anIlluminate\Http\Request(JSON body serialised to bytes), runsIlluminate\Contracts\Http\Kernel::handle(), converts the response back withPsrHttpFactory.transportHeaders()lists what HttpFoundation adds (cache-controlat least; the run decides,decisions.mdrecords).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 atdatabase/polaris.sqlite),.env.example,src/FileMailer.php(the JSON-lines mailbox),bin/setup,bin/walkthrough.sh, README.- CI: the
qamatrix gains the stepPOLARIS_HARNESS=Polaris\Laravel\Tests\Harness vendor/bin/phpunit --testsuite functional; ademo-laraveljob mirrors the Slim one.
5. Symfony (WP2)
Section titled “5. Symfony (WP2)”packages/symfony, Polaris\Symfony\, symfony/framework-bundle ^7.4 || ^8.0.
PolarisBundle(AbstractBundle):configure()declares the tree of §3.1 underpolaris:(secrets,auth,rate_limits,database: { dsn, user, password }ordatabase: { connection: <service id> }accepting a PDO or a Doctrine DBALConnection(getNativeConnection()),cache(a PSR-16 service id, default aPsr16Cacheovercache.app),logger,mailer,sms, the ports,path_prefix);loadExtension()registersPolaris,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, …) underpolaris.path_prefix(the bundle’s setting, so the pipeline strips the same prefix), all toPolarisController(Request→ PSR-7 →Pipeline::handle()→Response;Attributes::IP_ADDRESS=$request->getClientIp()). - Events:
symfony/event-dispatcheris PSR-14 but dispatches by exact class;PolarisEventSubscribersubscribesPolaris::listeners()to every class inPolaris\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 withWWW-Authenticate: Bearer) forsecurity.firewalls.<name>.custom_authenticators, no user provider needed;Security\PolarisUser(UserInterface) with the token; rolesROLE_USERplus the Polaris roles asROLE_POLARIS_<ROLE>(a mapping, not a policy). - Console:
polaris:schema:create,polaris:schema:drop, and the four CLI commands renamed, taggedconsole.command. Tests\Harness: aKernelwithMicroKernelTrait, 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’sservice: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/meroute behind the authenticator,bin/setup,bin/walkthrough.sh, README.
6. Yii (WP3)
Section titled “6. Yii (WP3)”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(thePolaris,Graph,Pipelinedefinitions fromparams['polaris'], §3.1;databaseis a DSN, or the application’sPDO,yiisoft/dbconnection (getPDO()) orDatabaseAdapterdefinition),config/params.php(the defaults and theyiisoft/yii-consolecommand map),config/routes.php(one named route per manifest endpoint under the prefix,polaris.auth.loginand so on, whose action isPolarisController::handle, the whole pipeline; PSR-15 native, no bridge),config/di-console.php(the commands, renamed throughsetName()). - Events:
yiisoft/event-dispatcheris PSR-14 but dispatches by exact class; the plugin’sconfig/events-web.phpandevents-console.phpmap everyPolaris\Eventclass toPolarisListener, the Yii way. - Auth:
Auth\PolarisAuthenticationMethod(Yiisoft\Auth\AuthenticationMethodInterface, bearer,WWW-Authenticate: Bearerchallenge) returningAuth\PolarisIdentity(IdentityInterfacewith the user and the token); thepolaris/authenticationdefinition isyiisoft/auth’sAuthenticationmiddleware with it and a 401 in Polaris’s envelope, for the host’s routes. - Console: the six CLI commands (
schema:createandschema:dropincluded) aspolaris:*, through theyiisoft/yii-consolecommand map. Tests\Harness: aYiisoft\Di\Containerbuilt from the plugin’s config files plus what an application provides (PSR-17 factories, the route collection, the dispatcher, the HTTP application onRequestBodyParserand the router) with the test’s instances as definitions;Yiisoft\Yii\Http\Application::handle(); no transport headers.examples/yii: ayiisoft/app-shaped minimal host on the HTTP and console runners:config/{params,di,di-web,routes,events}.php,public/index.php,yii, a/app/meroute behindpolaris/authentication,bin/setup,bin/walkthrough.sh, README.