Skip to Content
Architecture

Architecture overview

VCTRbase is a multi-tenant Laravel 12 + Inertia + React platform for automotive dealers. This overview covers how a request flows through the stack, how each dealer group’s data stays isolated, and how the plugin platform loads and runs extensions.

1. Decisions & rationale (ADR-style)

vctrbase-php is a port of the Next.js VCTRbase app to a Laravel + Inertia + React stack, reusing the same PostgreSQL schema so both apps speak the same data model.

LayerDecisionRationale
FrameworkLaravel 12Laravel 12 uses bootstrap/app.php for wiring (no app/Http/Kernel.php); mature middleware/queue/scheduler primitives map cleanly onto the Next.js host responsibilities.
FrontendInertia + React 19Keeps the React component model of the original SPA while letting Laravel own routing, auth, and server state — no separate API-for-the-UI layer.
DatabasePostgreSQL 16The schema of record is a 100-table dump restored from docker/postgres/init/01-schema.sql (101 live once migrate adds the migrations ledger; measured 2026-07-22 — this said 126); the single remaining Laravel migration provisions the app_user runtime role rather than regenerating the schema. Postgres RLS (ENABLE ROW LEVEL SECURITY) is preserved.
MigrationsSchema dump as source of truth + Laravel migrations on topThe dump defines the baseline (enums, tables, RLS policies, FKs); database/migrations/** adds only deltas. This keeps 1:1 parity with the Next.js schema.
Queue / cacheRedis 7Backs the queue and cache stores; plugin scheduled jobs are dispatched onto it (see §7).
API keysSanctum-style bearer keys, scopedExternal + plugin REST access under /api/v1 uses hashed bearer keys (api_keys.key_hash) validated against a capability-scope registry (ApiScopes), distinct from the human RBAC model.
TenancyThree logical tiers: agency → group → rooftopA single memberships table carries tenant_type + tenant_id; the physical tenant tables (agencies, groups, rooftops) are FK-linked (see §5).

Parity milestone. Every first-party plugin reached 1:1 core parity (the 2026-07-07 milestone — see superpowers/2026-07-07-MILESTONE-parity-complete.md). There are now 20 functional first-party plugins: 12 ship in-tree under plugins/ (alongside the hello/sample-esm samples — 14 directories in all) and 8 have been extracted to signed external marketplace repos that install at runtime. Whole-system parity sits at ~87%; the authoritative status is docs/parity/.

Divergence note. Where the port intentionally improves on core behavior it is tagged QOL DIVERGENCE in code and registered in superpowers/qol-divergences.md. The billing gate (§2) is one such divergence.


2. Request lifecycle & middleware

Middleware is wired in bootstrap/app.php. There are two distinct authenticated paths, and they bind the same contract — App\Support\TenantContext — so every downstream consumer (billing gate, audit, RBAC) works identically regardless of how the caller authenticated.

The tenant middleware GROUP (not a bare alias) is:

tenant = [ ResolveTenantContext, EnsureTenantNotDelinquent ]

The session-api middleware GROUP (bootstrap/app.php:68-71) authenticates the in-app, session-cookie caller of /api/v1 — the path the Inertia UI and an extracted plugin’s in-app pages use (see §3):

session-api = [ auth:sanctum, tenant ]

It resolves the logged-in user through Sanctum’s web-guard fallback and then binds the same TenantContext as the session path; it is a separate stack from the Bearer-only api group (routes/api.php, EnsureScope). EnsureFrontendRequestsAreStateful is deliberately omitted — these routes already sit under the framework’s web group (see the comment at bootstrap/app.php:57-67).

Aliases:

AliasClassPurpose
canApp\Http\Middleware\EnsurePermissionRBAC permission check
scopeApp\Http\Middleware\EnsureScopeAPI-key capability check
plugin.enabledApp\Http\Middleware\EnsurePluginEnabledplugin enable-gate for a tenant

AttachTraceId is prepended to the API stack and appended to the web stack, so every request carries a traceId used in the response envelope and audit trail.

Session path (Inertia / web)

ResolveTenantContext reads the authenticated user and the session active_tenant (falling back to the user’s first active membership, aborting 403 if none), then binds a TenantContext whose permissions come from PermissionResolver::forUser(). EnsureTenantNotDelinquent runs immediately after (as part of the tenant group) so the billing gate always sees a resolved tenant. Permission-gated routes additionally carry can:<permission>.

API-key path (/api/v1)

EnsureScope validates the Authorization: Bearer <key> header via ScopedApiKeyResolver::resolveScopedApiKey($bearer, $capability), then binds a machine-to-machine context via TenantContext::forApiKey($type, $id, $traceId) (sentinel empty userId — there is no human actor).

  • 401 — missing/invalid Authorization header, or key not found / inactive.
  • 403 — key revoked, expired, or lacking the required capability scope.

Billing gate (EnsureTenantNotDelinquent)

A QOL DIVERGENCE over core: instead of hand-picking individual mutations and returning 403, this shared middleware gates all write routes of any plugin whose manifest declares billingGate.blockOnDelinquent = true.

  • Writes only. $request->isMethodSafe() short-circuits — GET/HEAD/OPTIONS are never gated. Only POST/PUT/PATCH/DELETE reach the check.
  • Plugin-scoped. The plugin slug is the first dotted segment of the route name; a non-plugin route or a plugin with no manifest passes through.
  • Opt-in. Only fires when the resolved PluginManager::manifest($slug)->billingGate() has blockOnDelinquent === true.
  • 402 Payment Required on a blocked write (Response::HTTP_PAYMENT_REQUIRED, x402-aligned) for JSON/API callers; a redirect-back with a flash error for web.
  • Fail-open. Any missing signal or lookup error (BillingStatusProvider::forTenant throwing, no bound TenantContext, etc.) allows the write.

5. Tenant model

Tenancy is three logical tiers — agency → group → rooftop — expressed through a tenant_type enum and a single unified memberships table.

  • tenant_type enum (01-schema.sql): 'agency', 'group', 'rooftop'.
  • memberships carries (user_id, tenant_type, tenant_id, role_key, status, permission_overrides_json, invited_by). A user can belong to many tenants at once; the active one lives in the session (active_tenant).
  • TenantContext (app/Support/TenantContext.php) exposes userId(), activeTenantType(), activeTenantId(), traceId(), permissions(), setActive(), and the static forApiKey() constructor. SYSTEM_ACTOR (00000000-0000-0000-0000-000000000000) is the reserved nil-UUID actor for cron/system writes.

Hierarchy: FK-enforced and logical

The parent columns do exist and are FK-enforced between the physical tenant tables (confirmed in 01-schema.sql):

  • groups.agency_idagencies.idnullable FK (groups_agency_id_agencies_id_fk). A group MAY stand alone without an agency.
  • rooftops.group_idgroups.idNOT NULL FK, ON DELETE RESTRICT (rooftops_group_id_groups_id_fk). A rooftop MUST belong to exactly one group.

However, the memberships.tenant_id (and api_keys.tenant_id, and every plugin table’s tenant_id) is polymorphic: it is not a single foreign key, because it may point at an agency, group, or rooftop row depending on tenant_type. The only FK on memberships is memberships.user_id → users.id (ON DELETE CASCADE). So the association from a membership/API-key/plugin row to its tenant is scope-logical (resolved via tenant_type + tenant_id), not FK-enforced. Tenant isolation for these polymorphic tables is enforced at the database by RLS (§6).


7. Plugin platform architecture

The plugin platform (app/Plugins/**) turns each plugin into a first-class, tenant-scoped module with its own routes, jobs, migrations, UI extensions, and KV store, tenant-scoped per §5–§6 above. This section is the host reference — how the platform discovers, installs, boots, and gates plugins.

Author-facing companion: PLUGINS.md is the plugin author’s guide — the four-axis build/location/trust/UI-delivery taxonomy, the anatomy walkthrough, the manifest schema, and the author-called platform classes (HostStore, PluginSettings, PluginScheduledJob, the platform events). Read that to build a plugin; read this to understand how the host runs one.

PluginManager + PluginLifecycle

PluginManager (constructed over one or more plugin roots) discovers plugins, exposes manifests(), manifest($slug), path($slug), source($slug) (in_tree | uploaded), trustLevel($slug), and aggregates host surfaces across enabled plugins: navItems(), widgets(), permissions(), scheduledTasks(), isEnabled($slug). It also boots providers (bootProviders()) — where a contract plugin’s register() runs, gated by TrustPolicy (see the three trust gates below).

PluginLifecycle drives the per-tenant state machine over plugin_installs. Its public operations include syncPluginRecord(), install(), enable($slug), disable($slug), uninstall($slug), purge($slug), and the trust-grant methods grant() / revoke() / grantFor() / allowsServerCode(). Whether a first-party plugin is turned on for a fresh tenant is governed by the manifest’s enabledByDefault flag (ensureTenantState()).

Install pipeline (PluginInstaller::installFromZip())

Operators install plugins by uploading a ZIP at /dashboard/plugins. The upload is handled by PluginInstaller::installFromZip():

public function installFromZip( string $zipPath, TenantContext $ctx, ?string $signatureB64 = null, ?string $expectedDigest = null, ): Plugin

The pipeline:

  1. Size guard — reject empty/unreadable archives and anything over the 20 MB cap.

  2. Digest + keyring verify. If $expectedDigest is given, the archive’s sha256 must match it exactly. The signature (if any) is checked against the trust keyring (App\Plugins\Marketplace\Keyring — see the three trust gates), yielding a MatchedKey (publisher + keyId) or null. If plugins.require_signature is true, an archive with no matching key is refused outright here, before the manifest is even read — this is a hard operator lockdown, independent of what the manifest declares.

  3. Seal the verified bytes. The already-verified bytes are written to a sealed temp file and opened from there (not the original upload path) — a TOCTOU mitigation so a race can’t swap the archive between verification and everything that follows.

  4. Manifest validation (ManifestValidator::validate()) and slug/version guards (can’t collide with an in-tree slug; upgrades require a strictly higher version).

  5. Executable-code admission (QOL divergence, defense in depth — two rules):

    • If the manifest declares a provider or uiMode: module (both mean the archive ships code that executes), it MUST have matched a trusted keyring key in step 2, or the install is refused — unsigned executable code never installs.
    • Independently, even when the manifest declares neither, an unsigned archive that nonetheless contains any src/*.php file is refused (a smuggling guard) — a declarative manifest can’t be used to sneak PHP past the check.
    • Unsigned, non-executable (truly declarative) archives are still allowed — this is the intentional low-friction path for third-party/community declarative plugins.
  6. Engines range checkengines.vctrbase semver constraint against the host version, checked before extraction so an incompatible upload fails cheap.

  7. Safe-extract to staging — only now, after every guard above has passed, is the archive actually extracted to disk, hardened against Zip-slip path traversal and symlink escapes (SafeExtractor).

  8. Atomic moverename() the staged tree into storage/app/plugins/{slug}/.

  9. Register — the plugin’s DB row is written with the trust level returned by FirstPartyKeys::trustLevelFor($keyId, $signatureMatched) (app/Plugins/FirstPartyKeys.php:66), called at app/Plugins/PluginInstaller.php:278. There are three tiers, not two:

    • a first-party key id — one on the locally committed plugins.first_party_key_ids allowlist (config/plugins.php:73) — → 'signed_first_party';
    • any other valid keyring signature'signed_third_party' (the signature is genuinely valid, but it is not ours: its server code falls through to the per-tenant consent grant, it does not get platform trust);
    • no signature match'untrusted'.

    A keyring match alone therefore never confers signed_first_party — first-partiness is decided separately against the allowlist (FirstPartyKeys::isFirstParty(), app/Plugins/FirstPartyKeys.php:39). PluginManager::refresh() makes the freshly-extracted plugin discoverable before its migrations run.

  10. Auto-enable per the manifest’s enabledByDefault, or refresh an existing install on upgrade.

First-party bundled plugins with enabledByDefault: true are auto-installed for new tenants through ensureTenantState(); the rest (Hello included, at enabledByDefault: false) install but stay disabled until an operator enables them.

Boot

bootstrap/app.php calls PluginManager at two points: bootProviders() (registration, gated by TrustPolicy) during boot, and scheduledTasks() inside withSchedule() to register each plugin’s cron via PluginScheduleDispatcher::dispatchTask(). PluginManager::discover() always registers every plugin’s manifest/path/source first (so nav/widgets/permissions aggregation works even for unbooted plugins); only plugins that pass TrustPolicy::canServerCodeRun() actually get their register() called. See the three trust gates for the full gate chain.

The three trust gates

Three independent gates decide whether a plugin’s server code ever executes. All three must agree — they close different windows (install-time, boot-time, and per-class autoload-time), and a defect in any one is still caught by the others.

Gate 1 — install-time admission (PluginInstaller)

Covered in full under the install pipeline above: executable code (provider or uiMode:module) must match a trusted keyring key or the archive is refused outright, including a defense-in-depth check that an unsigned archive can’t smuggle a src/*.php tree past a declarative-looking manifest.

The trust keyring (App\Plugins\Marketplace\Keyring)

A multi-publisher keyring, not a single registry key. Keyring::verify($bytes, $sigB64) checks the signature against every non-revoked key from three sources, in precedence order:

  1. Local hard-trusted entriesconfig('plugins.trusted_keys'), operator-configured, empty by default.
  2. Back-compat single keyconfig('plugins.registry_pubkey'), synthesized into a one-entry {publisher: 'VCTRS', keyId: 'registry-pubkey'} keyring entry when set.
  3. Remote keyringtrusted-keys.json fetched from the marketplace base_url (cached, same Http::get() graceful-degradation as RemoteRegistry). Its revocations[] list suppresses any matching keyId from any source, including the local ones.

The first non-revoked key whose public key verifies the signature wins, returning a MatchedKey(publisher, keyId). config('plugins.require_signature') is the only switch that hard-requires a signature on every upload; setting registry_pubkey alone does not imply it.

Keyring membership does not decide platform execution — a separate allowlist does. Because this keyring merges a remotely-fetched trusted-keys.json, a match proves only that a signature is valid, never that the archive is ours. The tier that grants platform-level trust (signed_first_party) is keyed exclusively on config('plugins.first_party_key_ids') (config/plugins.php:73) — a locally committed key-id allowlist — via FirstPartyKeys::isFirstParty() (app/Plugins/FirstPartyKeys.php:39). Matching is on keyId, never publisher, so a compromised remote keyring cannot mint first-party trust by calling itself "VCTRS". Setting registry_pubkey does not add a key to this allowlist (config/plugins.php:33).

Gate 2 — boot-time (TrustPolicy::canServerCodeRun($slug))

Whether an installed plugin’s provider PHP may execute (i.e. whether PluginManager::bootProviders() calls its register()). The tiers, in order:

  1. Bundled (in_tree)PluginManager::source($slug) === 'in_tree'trusted, unconditionally.
  2. Signed first-partyPluginManager::trustLevel($slug) === 'signed_first_party' (the DB plugins.trust column) → trusted, tenant-independent, checked at app/Plugins/TrustPolicy.php:51. This tier is stamped by the plugins.first_party_key_ids allowlist, not by the keyring match alone — the code’s own warning is that $match !== null “must never by itself confer the platform-level signed_first_party tier” (app/Plugins/PluginInstaller.php:112-116). The comparison is an exact === 'signed_first_party'; a signed_third_party plugin (valid signature, key not on the allowlist) deliberately does not match here and falls through to tier 3. This is what lets a genuinely first-party signed upload boot at app-boot with no TenantContext bound at all.
  3. Uploaded — signed_third_party, unsigned, or untrusted — requires an active plugin_grants row with allow_server_code = true for the active tenant (the per-tenant consent path). The grant flag alone is not consent: it must still refer to the live artifact, so TrustPolicy additionally requires PluginGrant::consentIdentityMatchesLiveArtifact() (B9, app/Plugins/TrustPolicy.php:88) — the grant’s stored (publisher, key_id) must match the installed artifact’s, with a NULL identity on either side failing closed. This whole tier is fail-closed: with no bound TenantContext (CLI, artisan, tests without actingAs) uploaded provider code never runs regardless of any grant, the grant query duplicates the tenant scope with explicit tenant_type/tenant_id clauses as defense-in-depth against the global scope ever being bypassed, and any DB error defaults to deny.

Gate 3 — per-class autoload-time (RuntimeAutoloader)

A third, independent backstop that runs later than boot, at the moment any Vctrs\Plugins\* class is first referenced. Bundled plugins never reach this loader — Composer’s PSR-4 map resolves them first, and the closure defers to Composer whenever it already knows the class. For an uploaded plugin’s class, the loader calls PluginManager::trustLevel($slug) and only requires the file when it’s exactly 'signed_first_party'; any lookup failure (container/DB error, unknown slug) fails closed to “don’t load.” It also independently guards path containment (rejects any class-name segment containing .., a path separator, or a NUL byte, and confirms the resolved realpath stays inside the plugin’s own src/ directory) — closing both directory-traversal and symlink-escape vectors on top of the trust check.

Put together: an uploaded, unsigned plugin’s PHP is refused at install (Gate 1); even if that were somehow bypassed, its provider would never boot (Gate 2); and even if a reference to one of its classes appeared anywhere, the class file would never be required (Gate 3).

Declarative plugins carry no server code at all, so none of these three gates apply to them — only their host-rendered views run, and there is nothing to trust.

Last updated on