Admin API & Typed SDK Workflow
The admin platform uses a generate-once-consume-everywhere pattern for its HTTP contract. The backend emits an OpenAPI 3.0 spec, the frontend turns it into a typed Axios SDK, and stores call the SDK instead of stringly-typed axios.get('/api/...') URLs.
Toolchain
| Side | Tool | Output |
|---|---|---|
| Backend | dedoc/scramble | OpenAPI 3.0 JSON via php artisan scramble:export |
| Frontend | @hey-api/openapi-ts | Typed SDK (src/api/generated/) and TypeScript types |
| Frontend | @hey-api/client-axios | Axios runtime, sharing src/lib/axios.ts |
Tenant services follow the exact same pattern with their own swagger/{service}.json; see the tenant-frontend openapi-ts.config.ts.
Day-to-day workflow
Adding or changing an admin endpoint
-
Edit a controller in
main-backend/app/Http/Controllers/{Tenant/,Admin/,…}/. -
(Optional but recommended) annotate the method with PHPDoc — Scramble uses these to enrich the spec:
/**
* Activate a suspended tenant.
*
* @response array{success:bool, data:Tenant}
*/
public function activate(Tenant $tenant): JsonResponse { ... } -
Regenerate the spec and copy it to the frontend:
# main-backend
php artisan scramble:export
cp docs/api.json ../main-frontend/swagger/admin.json
# main-frontend
npm run api:generate -
The new method appears immediately in
src/api/generated/sdk.gen.ts. TypeScript will surface every store and component that needs to update.
Calling the SDK from a Pinia store
import { sdk } from '@/api'
import type { Tenant } from '@/types/api'
export const useTenantStore = defineStore('tenant', () => {
async function fetchTenant(id: number): Promise<Tenant | null> {
const { data } = await sdk.tenantsShow({ path: { tenant: String(id) } })
return data?.data ?? null
}
return { fetchTenant }
})
The SDK validates the path/query/body shapes at compile time. Renames or removed routes immediately turn into red squiggles in every consumer.
Why we still keep src/types/api.ts
Scramble generates response types from controller PHPDoc, but most controllers do not yet have @response annotations. While we backfill those, hand-curated domain types in src/types/api.ts are the source of truth for stores. Once a domain has full PHPDoc coverage, the hand-typed interface can be dropped in favour of the generated *Response type.
Authentication
src/api/index.ts wires the generated client to share the application-wide axios instance:
import api from '@/lib/axios'
import { client } from './generated/client.gen'
client.setConfig({ axios: api, baseURL: import.meta.env.VITE_API_URL + '/api/admin' })
That means every SDK call participates in:
- Bearer token injection from
localStorage - 401-triggered refresh-token flow with deduped concurrent retries
- Global 4xx/5xx toasts via
useNotificationStore - 422 toasts with the first validation error
Accept-Languageheader fromlocalStorage.language
Local development tips
-
Run the backend on
:8002(the default) beforenpm run api:export; otherwise the curl will fail. -
npm run api:generatere-emits the SDK deterministically; commit the generated files so frontend builds don't depend on the backend running. -
The OpenAPI spec is written to
main-backend/docs/api.json. Add the file to.gitignoreif you don't want to commit the backend artifact (the canonical copy ismain-frontend/swagger/admin.json). -
If
php artisanfails with "opentelemetry extension must be loaded", export the spec with the OTel auto-instrumentation disabled:OTEL_PHP_AUTOLOAD_ENABLED=false OTEL_SDK_DISABLED=true php artisan scramble:export
Migration story
Pre-refactor, the frontend had four parallel HTTP wrappers (lib/axios.ts, utils/axios.ts, utils/api.ts, composables/useApi.ts) and 113 : any types in stores. The post-refactor state:
- One axios wrapper (
src/lib/axios.ts) - Typed SDK (
src/api/generated/) wired to that wrapper - Domain types (
src/types/api.ts) replacing the worstanyclusters - 50%+ reduction in
: anyoccurrences across the codebase
See main-backend split for the matching backend refactor.