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.
| Layer | Decision | Rationale |
|---|---|---|
| Framework | Laravel 12 | Laravel 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. |
| Frontend | Inertia + React 19 | Keeps the React component model of the original SPA while letting Laravel own routing, auth, and server state — no separate API-for-the-UI layer. |
| Database | PostgreSQL 16 | The 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. |
| Migrations | Schema dump as source of truth + Laravel migrations on top | The 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 / cache | Redis 7 | Backs the queue and cache stores; plugin scheduled jobs are dispatched onto it (see §7). |
| API keys | Sanctum-style bearer keys, scoped | External + 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. |
| Tenancy | Three logical tiers: agency → group → rooftop | A 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 DIVERGENCEin code and registered insuperpowers/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:
| Alias | Class | Purpose |
|---|---|---|
can | App\Http\Middleware\EnsurePermission | RBAC permission check |
scope | App\Http\Middleware\EnsureScope | API-key capability check |
plugin.enabled | App\Http\Middleware\EnsurePluginEnabled | plugin 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
Authorizationheader, 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/OPTIONSare never gated. OnlyPOST/PUT/PATCH/DELETEreach 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()hasblockOnDelinquent === 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::forTenantthrowing, no boundTenantContext, 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_typeenum (01-schema.sql):'agency','group','rooftop'.membershipscarries(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) exposesuserId(),activeTenantType(),activeTenantId(),traceId(),permissions(),setActive(), and the staticforApiKey()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_id→agencies.id— nullable FK (groups_agency_id_agencies_id_fk). A group MAY stand alone without an agency.rooftops.group_id→groups.id— NOT 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.mdis 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,
): PluginThe pipeline:
-
Size guard — reject empty/unreadable archives and anything over the 20 MB cap.
-
Digest + keyring verify. If
$expectedDigestis 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 aMatchedKey(publisher + keyId) ornull. Ifplugins.require_signatureistrue, 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. -
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.
-
Manifest validation (
ManifestValidator::validate()) and slug/version guards (can’t collide with an in-tree slug; upgrades require a strictly higher version). -
Executable-code admission (QOL divergence, defense in depth — two rules):
- If the manifest declares a
provideroruiMode: 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/*.phpfile 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.
- If the manifest declares a
-
Engines range check —
engines.vctrbasesemver constraint against the host version, checked before extraction so an incompatible upload fails cheap. -
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). -
Atomic move —
rename()the staged tree intostorage/app/plugins/{slug}/. -
Register — the plugin’s DB row is written with the trust level returned by
FirstPartyKeys::trustLevelFor($keyId, $signatureMatched)(app/Plugins/FirstPartyKeys.php:66), called atapp/Plugins/PluginInstaller.php:278. There are three tiers, not two:- a first-party key id — one on the locally committed
plugins.first_party_key_idsallowlist (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. - a first-party key id — one on the locally committed
-
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:
- Local hard-trusted entries —
config('plugins.trusted_keys'), operator-configured, empty by default. - Back-compat single key —
config('plugins.registry_pubkey'), synthesized into a one-entry{publisher: 'VCTRS', keyId: 'registry-pubkey'}keyring entry when set. - Remote keyring —
trusted-keys.jsonfetched from the marketplacebase_url(cached, sameHttp::get()graceful-degradation asRemoteRegistry). Itsrevocations[]list suppresses any matchingkeyIdfrom 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:
- Bundled (
in_tree) —PluginManager::source($slug) === 'in_tree'→ trusted, unconditionally. - Signed first-party —
PluginManager::trustLevel($slug) === 'signed_first_party'(the DBplugins.trustcolumn) → trusted, tenant-independent, checked atapp/Plugins/TrustPolicy.php:51. This tier is stamped by theplugins.first_party_key_idsallowlist, not by the keyring match alone — the code’s own warning is that$match !== null“must never by itself confer the platform-levelsigned_first_partytier” (app/Plugins/PluginInstaller.php:112-116). The comparison is an exact=== 'signed_first_party'; asigned_third_partyplugin (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 noTenantContextbound at all. - Uploaded —
signed_third_party, unsigned, oruntrusted— requires an activeplugin_grantsrow withallow_server_code = truefor the active tenant (the per-tenant consent path). The grant flag alone is not consent: it must still refer to the live artifact, soTrustPolicyadditionally requiresPluginGrant::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 boundTenantContext(CLI, artisan, tests withoutactingAs) uploaded provider code never runs regardless of any grant, the grant query duplicates the tenant scope with explicittenant_type/tenant_idclauses 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.