HTTP reference
Public CMS API
Externa exposes a headless Public CMS API under /api/v1 so websites, apps, and integrations can read (and optionally write) collection data without an admin session. Access is controlled by a separate permission model from Spatie admin permissions.
Two permission systems
| Surface | What it controls | How |
|---|---|---|
Admin UI (/settings/roles, /users, …) | Who can use the backoffice | Spatie PermissionEnum + EffectivePermissionResolver |
Public CMS API (/api/v1/...) | Who can create/read/update/delete collection items and files over HTTP | Tables collection_permissions (role × collection × action) and file_permissions (role × action, global) |
Having can-edit-collections or can-show-files in the admin does not grant /api/v1 access. You must configure Collection access and/or Files access on a role (usually public or a custom role bound to an API key).
Full setup walkthrough is below. Endpoint tables with request/response examples and the Bruno companion are at the end. Copyable TypeScript / OpenAPI / JSON Schema: Public CMS API client types. Related admin concepts: Roles & permissions, Effective permissions.
Bruno collection
Ready-to-run requests for every Public CMS + GraphQL path: github.com/qiick-io/externa-bruno. Install Bruno, open the repo folder as a collection, select env Local, copy .env.sample → .env (EXTERNA_API_KEY). Point base_url at local or staging. Docs below remain the source of example payloads; Bruno executes them.
Mental model
Request to /api/v1/...
│
▼
┌───────────────────────┐
│ ResolveApiAccess │
│ • Bearer ek_… ? │──yes──► validate key → role on api_keys.role_id
│ • no Bearer │──no───► actor = public role
└───────────────────────┘
│
▼
┌───────────────────────┐
│ CollectionPermission │ Missing grant = DENY
│ Guard / FilePermission│ Actions: create | read | read_private | update | delete
│ Guard (cached) │ Files are global; private visibility is per-file
└───────────────────────┘
│
▼
Controller runs (or 403)
| Actor | How you authenticate | Permissions come from |
|---|---|---|
| Anonymous / public | No Authorization header | System role public |
| API key | Authorization: Bearer ek_… | The role assigned to that key (never super-admin) |
Default deny: if there is no row in collection_permissions for (role_id, collection_id, action), or in file_permissions for (role_id, action), the action is forbidden.
Quick start (typical website read)
Goal: a public site can GET collections/items for posts, but cannot create/update/delete.
- Sign in as an admin who can edit roles (
can-edit-roles). - Open Roles → edit the
publicrole (system role; cannot be deleted, renamed, or assigned to users). - In Collection access, set Read to allowed (✓) for the collections you want public. Leave Create / Update / Delete as denied (✕).
- Optionally open Files access and grant Read if the site needs
/api/v1/files*or expanded image URLs on items. Grant Read private only for roles that should see files marked private in the file manager. - Save.
- Call without a key:
curl -sS http://externa-core.test/api/v1/collections \
-H 'Accept: application/json'
curl -sS http://externa-core.test/api/v1/collections/posts/items \
-H 'Accept: application/json'
- Confirm mutations fail:
curl -sS -X POST http://externa-core.test/api/v1/collections/posts/items \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-d '{"data":{}}'
# → 403
Setup guide (full)
1. Ensure migrations and seeds ran
You need:
- Role
public(is_system,is_assignable = false) fromRoleSeeder - Tables
collection_permissions,file_permissions, andapi_keys - Spatie permissions
can-show-api-keys/can-manage-api-keys(viapermissions:sync)
php artisan migrate
php artisan permissions:sync
php artisan db:seed --class=RoleSeeder # if public role is missing
2. Configure anonymous (public) access
| Step | Where | What |
|---|---|---|
| Edit role | Admin → Roles → public | Collection access and Files access are editable (no Spatie permission checkboxes) |
| Collection matrix | Per collection columns Create / Read / Update / Delete | ✓ = allowed, ✕ = denied |
| Files row | Global Create / Read / Read private / Update / Delete | Applies to all /api/v1/files*; read_private unlocks files with effective visibility private |
| Save | Persist collection_permissions / file_permissions | Guard caches are flushed on sync |
Public role locks
Public role cannot be renamed or deleted, assigned to users or groups, or given Spatie admin permissions. It is only the anonymous API actor.
3. Create a role for partners / backends (optional)
For write access or private read without opening the internet to everyone:
- Roles → Create a normal role (e.g.
api-website,api-partner). - You may leave Spatie admin permissions empty — they do not affect
/api/v1. - Set Collection access for the collections/actions that key should have.
- Set Files access if the key should list/upload/stream files.
- Save.
4. Issue an API key
| Step | Detail |
|---|---|
| UI | Settings → Access → API Keys (/settings/api-keys) |
| Permissions | View list: can-show-api-keys. Create/revoke: can-manage-api-keys |
| Fields | Name, role (not super-admin), optional IP allowlist, optional rate limit / expiry |
| Secret | Shown once as ek_… — store it; Externa only keeps a hash |
curl -sS http://externa-core.test/api/v1/collections \
-H 'Accept: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET"
5. Point your client / Bruno
- Base URL: your app URL (e.g.
http://externa-core.testor staging) - Paths always under
/api/v1/... - Clone externa-bruno: open the folder in Bruno, env Local (or a copy for staging),
.env→EXTERNA_API_KEY, then setcollection_slug/item_id/file_idon requests
How authentication is resolved
Middleware: App\Http\Middleware\ResolveApiAccess on all routes/api.php v1 routes.
No Bearer token → public
- Loads role named
public - If missing → 503 (
Public API role is not configured.) - Binds
ApiAccesswithactor = public
Bearer token → API key
- Hash the secret and look up
api_keys - Reject if missing, revoked, or expired → 401
- Enforce optional IP allowlist → 401 if client IP not listed
- Enforce rate limit (key override or
API_KEY_RATE_LIMIT, default 60/min) → 429 - Reject if linked role is
super-admin→ 401 - Bind
ApiAccesswithactor = api_keyand that role - Touch
last_used_at
Route group also uses Laravel throttle:api for coarse traffic shaping.
Collection access matrix
Stored in collection_permissions:
| Column | Meaning |
|---|---|
role_id | Spatie/roles id (public or custom) |
collection_id | Target collection |
action | create | read | update | delete |
allowed | Must be true to grant (rows are created only for grants) |
Service: CollectionPermissionGuard (cached per role; flushed when the role matrix is saved via CollectionPermissionSync).
UI: role create/edit → Collection access table. Toggle buttons show ✓ (allowed) or ✕ (denied).
Files access matrix
Stored in file_permissions (global per role — not per folder):
| Column | Meaning |
|---|---|
role_id | Spatie/roles id (public or custom) |
action | create | read | read_private | update | delete |
allowed | Must be true to grant (rows are created only for grants) |
Service: FilePermissionGuard / FilePermissionSync. UI: role create/edit → Files access.
Public role pattern
For a typical marketing site on the public role: Read = ✓, Read private = ✕. Public assets stream from /api/v1/files*; on items they expand only with ?include=files. Private files return 403 on /api/v1/files/{id} (and content/transform) and are omitted from list / item expanders. Grant Read private only on a custom API-key role when a trusted backend needs private assets.
Public vs private files
Each file/folder has an access override (files.access): public, private, or null (inherit from nearest ancestor). Effective visibility walks parents; root with no override is public.
| Role grants | Public file | Private file (effective) |
|---|---|---|
no read | Files API 403; item field { "id", "access": "denied" } | Files API 403; item field null / omitted (no id leak) |
read only | List / show / content / full expand | Filtered from list; show/content 403; item field null / omitted |
read + read_private | Same as public | Full access + expand |
Admin file manager (Spatie can.manage.files) always sees all files; use the Visibility control on the detail panel (Inherit / Public / Private). Private folders apply to children unless a child overrides.
Endpoint reference
Base path: /api/v1. Controllers under App\Http\Controllers\Api\V1\. Examples use http://externa-core.test and a sanitized key ek_YOUR_SECRET — replace with your Herd host and real key.
Auth header when using an API key (omit for anonymous / public role):
Authorization: Bearer ek_YOUR_SECRET
Accept: application/json
Collections
| Method | Path | Required action | Notes |
|---|---|---|---|
GET | /api/v1/collections | (implicit read) | Returns only collections where the actor has read |
GET | /api/v1/collections/{slug} | read | Includes field schema summary |
List collections
curl -sS http://externa-core.test/api/v1/collections \
-H 'Accept: application/json'
# optional: -H "Authorization: Bearer ek_YOUR_SECRET"
{
"data": [
{
"id": 1,
"name": "Posts",
"slug": "posts",
"is_singleton": false
}
]
}
With no read grants: "data": [] (still 200, not 403).
Get collection
curl -sS http://externa-core.test/api/v1/collections/posts \
-H 'Accept: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET"
{
"data": {
"id": 1,
"name": "Posts",
"slug": "posts",
"is_singleton": false,
"fields": [
{
"id": 10,
"name": "title",
"type": "text",
"settings": {}
},
{
"id": 11,
"name": "status",
"type": "select",
"settings": {}
}
]
}
}
Items
| Method | Path | Required action | Success |
|---|---|---|---|
GET | /api/v1/collections/{slug}/items | read | 200 + data + pagination meta |
GET | /api/v1/collections/{slug}/items/{id} | read | 200 |
POST | /api/v1/collections/{slug}/items | create | 201 — body { "data": { …field values } } |
PATCH | /api/v1/collections/{slug}/items/{id} | update | 200 — body { "data": { … } } |
DELETE | /api/v1/collections/{slug}/items/{id} | delete | 204 |
List items
curl -sS 'http://externa-core.test/api/v1/collections/posts/items?page=1&per_page=15' \
-H 'Accept: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET"
Advanced filter (same dialect as admin / GraphQL):
curl -sS 'http://externa-core.test/api/v1/collections/posts/items?filter[status][_eq]=published&page=1&per_page=15' \
-H 'Accept: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET"
Also: filter[title][_contains]=hello, filter[status][_in]=published,draft, filter[body][_nnull]=1. See Advanced list filters.
{
"data": [
{
"id": 1,
"collection_id": 3,
"data": { "title": "Hello", "status": "published" },
"created_at": "2026-07-20T12:00:00+00:00",
"updated_at": "2026-07-20T12:00:00+00:00"
}
],
"meta": {
"current_page": 1,
"last_page": 1,
"per_page": 15,
"total": 1
}
}
Get item
curl -sS http://externa-core.test/api/v1/collections/posts/items/1 \
-H 'Accept: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET"
{
"data": {
"id": 1,
"collection_id": 3,
"data": {
"title": "Hello",
"status": "published"
},
"created_at": "2026-07-20T12:00:00+00:00",
"updated_at": "2026-07-20T12:00:00+00:00"
}
}
Optional query: locale, include_all_translations, include (see Expand with include and the query table below).
Create item
curl -sS -X POST http://externa-core.test/api/v1/collections/posts/items \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET" \
-d '{"data":{"title":"New post","status":"draft"}}'
{
"data": {
"id": 42,
"collection_id": 3,
"data": {
"title": "New post",
"status": "draft"
},
"created_at": "2026-07-20T12:00:00+00:00",
"updated_at": "2026-07-20T12:00:00+00:00"
}
}
Status 201. M2M junction values use { "related_item_id": 12, "meta": { "sort": 1 } } — see M2M junction metadata. Nested blocks payloads: Field types — Blocks.
Update item
curl -sS -X PATCH http://externa-core.test/api/v1/collections/posts/items/42 \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET" \
-d '{"data":{"title":"Updated title","status":"published"}}'
Response 200 — same item shape as Get item.
Delete item
curl -sS -X DELETE http://externa-core.test/api/v1/collections/posts/items/42 \
-H 'Accept: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET"
Response 204 — empty body (soft-delete).
Files
| Method | Path | Required action | Notes |
|---|---|---|---|
GET | /api/v1/files | read | List: parent_id, search, page, per_page. Private files omitted without read_private |
GET | /api/v1/files/{id} | read (+ read_private if private) | Metadata + content/transform URLs |
GET | /api/v1/files/{id}/content | read (+ read_private if private) | Stream original bytes |
GET | /api/v1/files/{id}/transforms/{key} | read (+ read_private if private) | Project preset or size-{n} |
POST | /api/v1/files | create | Multipart upload (file) |
PATCH | /api/v1/files/{id} | update | Metadata / focal / rename |
DELETE | /api/v1/files/{id} | delete | Soft delete |
List files
curl -sS 'http://externa-core.test/api/v1/files?page=1&per_page=15' \
-H 'Accept: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET"
{
"data": [
{
"id": 12,
"parent_id": null,
"type": "file",
"access": "public",
"filename": "hero.jpg",
"title": "Hero",
"description": null,
"mime_type": "image/jpeg",
"extension": "jpg",
"filesize": 204800,
"width": 1920,
"height": 1080,
"focal_point_x": 0.5,
"focal_point_y": 0.5,
"url": "http://externa-core.test/api/v1/files/12/content",
"transforms": {
"thumb": "http://externa-core.test/api/v1/files/12/transforms/thumb"
},
"created_at": "2026-07-20T12:00:00+00:00",
"updated_at": "2026-07-20T12:00:00+00:00"
}
],
"meta": {
"current_page": 1,
"last_page": 1,
"per_page": 15,
"total": 1
}
}
Query: parent_id, search, page, per_page (1–100). Private rows are omitted without read_private (still 200).
Get file
curl -sS http://externa-core.test/api/v1/files/12 \
-H 'Accept: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET"
Response 200 — { "data": { … } } same shape as a list row. Private file without read_private → 403.
Get file content / transform
curl -sS -OJ http://externa-core.test/api/v1/files/12/content \
-H "Authorization: Bearer ek_YOUR_SECRET"
curl -sS -OJ http://externa-core.test/api/v1/files/12/transforms/thumb \
-H "Authorization: Bearer ek_YOUR_SECRET"
Streams bytes (Content-Type from the file / transform). Same auth rules as Get file.
Upload file
curl -sS -X POST http://externa-core.test/api/v1/files \
-H 'Accept: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET" \
-F 'file=@/path/to/image.jpg' \
-F 'parent_id=' \
-F 'name='
Multipart field file (required). Optional parent_id, name. Response 201 — { "data": { …FileResource } } (same shape as Get file).
Update file
curl -sS -X PATCH http://externa-core.test/api/v1/files/12 \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET" \
-d '{"title":"Hero","description":"Updated via Public CMS API","focal_point_x":0.35,"focal_point_y":0.6}'
Allowed metadata: title, description, location, download_name, focal_point_x / focal_point_y (0–1), name. Response 200 — updated FileResource.
Delete file
curl -sS -X DELETE http://externa-core.test/api/v1/files/12 \
-H 'Accept: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET"
Response 204 — empty body (soft delete).
Expand with include
Public REST item list/show responses are lean by default. File objects, mini-users, and related items are expanded only when you opt in with ?include=… (CSV, case-sensitive). GraphQL is unchanged (still expands files). Admin Inertia keeps raw IDs.
Breaking: files are no longer expanded by default
Clients that previously relied on automatic file expansion on /api/v1/.../items must add include=files. Without it, image / file / files values stay as stored IDs (or arrays of IDs).
GET /api/v1/collections/{slug}/items?include=files,author,categories
GET /api/v1/collections/{slug}/items/{id}?include=files
| Token / value | Effect |
|---|---|
files | Expand all image / file / files fields (and file slots inside blocks) via FileFieldExpander |
users | Populate user_created / user_updated mini-objects (otherwise those keys are null; user_*_id always present) |
| field name | For relation fields (relation, many_to_one, one_to_many, many_to_many, relation_many, relation_tree, m2a): hydrate related items at depth 1 |
Unknown tokens and non-relation field names are ignored (no 400). Nested hydrated items do not re-apply relation includes; they may still apply files / users if those tokens are in the same include list.
Default shape (no include)
data: raw stored values — files = id or[id, …]; relations = int /[{ related_item_id, meta }]/ m2a linksuser_created/user_updated:null
With include
include | Result |
|---|---|
files | File fields become objects (same rules as below) |
author (M2O) | data.author becomes a nested item resource (no further relation expand) |
tags (M2M) | Each entry becomes { "item": { … }, "meta": { … } } (unreadable related → link only) |
m2a_field | Each entry keeps related_collection_id / related_item_id and adds item when readable |
File expansion (only with include=files):
- With file:read on a public file: object(s) with
id,filename,title,mime_type,width,height,filesize,url,transforms - Without file:read on a public file:
{ "id": N, "access": "denied" }(no URLs) — breaking vs older{ "id": N }only - Private file without file:read_private:
null(single) or omitted fromfilesarrays — no id or url - Soft-deleted / missing files →
null(or omitted fromfilesarrays)
`access: denied`
If a public file field expands to { "id": N, "access": "denied" }, the actor’s role is missing Files → Read. Grant that action on the role (Settings → Roles) and retry. Private files still redact to null without Read private — they never return an id.
Permissions: related items hydrate only when readable for the actor (CollectionPermissionEnforcer). Unreadable → leave raw id / junction / m2a link (no nested payload).
Examples
# Lean list (file fields are IDs)
curl -sS 'http://externa-core.test/api/v1/collections/posts/items?page=1&per_page=15' \
-H 'Accept: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET"
# Expand files + author relation + mini-users
curl -sS 'http://externa-core.test/api/v1/collections/posts/items/1?include=files,author,users' \
-H 'Accept: application/json' \
-H "Authorization: Bearer ek_YOUR_SECRET"
{
"data": {
"id": 1,
"collection_id": 3,
"data": {
"title": "Hello",
"cover": {
"id": 12,
"filename": "hero.jpg",
"title": "Hero",
"mime_type": "image/jpeg",
"width": 1920,
"height": 1080,
"filesize": 204800,
"url": "http://externa-core.test/api/v1/files/12/content",
"transforms": {
"thumb": "http://externa-core.test/api/v1/files/12/transforms/thumb"
}
},
"author": {
"id": 7,
"collection_id": 2,
"data": { "name": "Ada" },
"created_at": "2026-07-20T12:00:00+00:00",
"updated_at": "2026-07-20T12:00:00+00:00",
"user_created_id": null,
"user_updated_id": null,
"user_created": null,
"user_updated": null
}
},
"created_at": "2026-07-20T12:00:00+00:00",
"updated_at": "2026-07-20T12:00:00+00:00",
"user_created_id": 1,
"user_updated_id": 1,
"user_created": { "id": 1, "name": "Admin", "email": "admin@example.com" },
"user_updated": { "id": 1, "name": "Admin", "email": "admin@example.com" }
}
}
TypeScript / OpenAPI shapes: Public CMS API client types. Bruno: List / Get Item with include query.
Query parameters (items)
| Param | Used on | Purpose |
|---|---|---|
page, per_page | list | Pagination (per_page clamped 1–100, default 15) |
filter[...] | list | Field filters (same idea as admin item query service) |
locale | list / show | Locale for flattened translatable fields. Must be an enabled project content locale; otherwise 422 |
include_all_translations | list / show | When true, include enabled locales only in data (orphans for disabled locales are omitted from the response but kept in DB) |
include | list / show | CSV opt-in expand: files, users, and/or relation field names (depth 1). See Expand with include |
Permission → behaviour cheat sheet
| Grants on role | List collections | Get collection / items | Create | Update | Delete |
|---|---|---|---|---|---|
| (none) | [] empty list | 403 | 403 | 403 | 403 |
read only | includes collection | 200 | 403 | 403 | 403 |
read + create | yes | yes | 201 | 403 | 403 |
read + update | yes | yes | 403 | 200 | 403 |
read + delete | yes | yes | 403 | 403 | 204 |
| all four | full CRUD | yes | yes | yes | yes |
List vs show
GET /collections never 403s for missing read: it simply omits collections you cannot read. GET /collections/{slug} and item routes do 403 when read is missing.
HTTP errors
| Status | Typical cause |
|---|---|
401 | Bad/revoked/expired key, IP not allowlisted, key on super-admin |
403 | Role lacks the required collection or file action; or origin gate (Origin not allowed. / Origin required or API key.) when public_api_allowed_origins is set |
404 | Unknown collection slug or item id |
422 | Validation failed on create/update payload |
429 | API key rate limit exceeded |
503 | public role missing from DB |
CORS and config
| Item | Detail |
|---|---|
| Routes | routes/api.php, prefix api (bootstrap/app.php) |
| CORS (env) | config/cors.php — paths api/*; origins from CORS_ALLOWED_ORIGINS (default *) when project allowlist is empty |
| CORS (project) | Non-empty public_api_allowed_origins → effective CORS for api/* = that list (ConfigurePublicApiCors) |
| Origin gate | Non-empty allowlist → EnforcePublicApiOrigin on /api/v1/* and /api/graphql (browser Origin must match; no Origin → valid API key required) |
| Rate default | config/api.php ← API_KEY_RATE_LIMIT (default 60) |
API_KEY_RATE_LIMIT=60
CORS_ALLOWED_ORIGINS=https://www.example.com,https://staging.example.com
Configure the project allowlist in Settings → Project → Public API (not only via env). Full behavior, Bruno notes, and security limits: Client types — Origin allowlist.
Admin UI surfaces
| Screen | Path | Purpose |
|---|---|---|
| Role edit → Files access | /settings/roles/{id}/edit | Global file API create/read/read_private/update/delete |
| Role edit → Collection access | /settings/roles/{id}/edit | Grant/deny create/read/update/delete per collection |
| API Keys | /settings/api-keys | Create keys (secret once), revoke |
| File manager search | /files | Search is server-side only (GET /files/list?search=); load-more stays enabled while searching |
| File picker | drawer | Same BE search; supports load-more pagination |
Admin Spatie permissions for the API Keys screen:
can-show-api-keys
can-manage-api-keys
Bruno collection (externa-bruno)
Ready-to-use companion: https://github.com/qiick-io/externa-bruno — classic Bruno layout (bruno.json + folders). Docs above are the source of example payloads; Bruno executes them.
- Install Bruno, clone / open the
externa-brunofolder as a collection - Select environment Local (
environments/Local.bru; defaultbase_url=http://externa-core.test) - Copy
.env.sample→.env, setEXTERNA_API_KEY(andEXTERNA_WEBHOOK_SECRETif needed) - Set request vars
collection_slug/item_id/file_idas needed - Staging / remote: duplicate Local in Bruno, set
base_url(no trailing slash) and the matching key in.env - Folder PublicApi inherits Bearer auth from
folder.bru(except List Collections Anonymous, which usesauth: none) - If Allowed origins is set in project settings, Bruno calls without an Origin header require a valid
api_key(anonymous requests will 403) - Subfolders: Files, GraphQL, Webhooks (
Verify Webhook Signature— sample payload / signing helper; default__webhook_receiver_docs_only__URL is a placeholder and 404s on externa-core; see Outbound webhooks)
Each request includes a docs panel describing method, auth, required permission, and expected status codes. Full open notes: externa-bruno README.
Tests
- Pest feature suites:
PublicCollectionApiTest,PublicFilesApiTest,PublicApiOriginGateTest - Cover anonymous deny/allow per action, API key gating, origin allowlist, file content/transform 403 without read, item
?include=(files / relations / users), upload warm job, and focal cover.
# Use PHP 8.4 if your shell `php` is older (Herd):
php84 artisan test --filter=PublicCollectionApiTest
php84 artisan test --filter=PublicFilesApiTest
php84 artisan test --filter=PublicApiOriginGateTest
Source map
| Concern | Location |
|---|---|
| Routes | routes/api.php |
| Auth middleware | app/Http/Middleware/ResolveApiAccess.php |
| Origin / CORS | EnforcePublicApiOrigin, ConfigurePublicApiCors, Support\Api\PublicApiOrigin |
| Guard / sync | CollectionPermissionGuard / CollectionPermissionSync, FilePermissionGuard / FilePermissionSync |
| Controllers | app/Http/Controllers/Api/V1/* |
| Models | ApiKey, CollectionPermission, FilePermission, Role (isPublic, system flags) |
| Admin API keys | app/Http/Controllers/Admin/ApiKeyController.php |
| Config | config/api.php, config/cors.php; project key public_api_allowed_origins |
| Bruno | github.com/qiick-io/externa-bruno |
| Pest | tests/Feature/Api/PublicApiOriginGateTest.php |
Related pages
- Public CMS API client types — TypeScript, OpenAPI 3, JSON Schema downloads
- Roles & permissions — editing roles and the Collection access matrix
- Effective permissions — admin Spatie model (separate from this API)
- Outbound webhooks — Externa → your URL (HMAC); not the same as API keys
- Collections API — session admin routes under
/collections(not/api/v1) - Routing overview — how
api.phpis registered - Testing — Pest layout including
Feature/Api
Field-level rules & item filters
collection_permissions.rules (JSON) on each Collection access grant (admin Roles UI → Fields & item filter):
{
"fields": {
"title": { "read": true, "create": true, "update": true },
"status": { "read": true, "create": false, "update": false }
},
"item_filter": {
"logic": "and",
"rules": [{ "field": "status", "operator": "equals", "value": "published" }]
}
}
| Rule | Effect |
|---|---|
Empty fields map | All fields allowed (backward compatible) |
fields.*.read: false | Stripped from list/show responses |
fields.*.create / update: false | Rejected on write (403/422 path via guard) |
item_filter | Hides non-matching items on list/show (404) and blocks update/delete (403) |
Operators for item_filter: equals, not_equals, empty, not_empty (AND logic only). Applied for Public REST, GraphQL, and non–super-admin admin item access via CollectionPermissionGuard / CollectionPermissionRules.
Pest: tests/Feature/Api/CollectionPermissionRulesTest.php.
GraphQL (/api/graphql)
Dedicated guide: GraphQL.
Summary: Lighthouse endpoint at /api/graphql only (route name graphql; no /graphql app route). Same ResolveApiAccess + collection guards as REST. Queries: collections, collection(slug), items, item. Mutations: createItem, updateItem, deleteItem (soft-delete). Filter dialect matches REST/admin (perPage camelCase). Bruno: externa-bruno PublicApi/GraphQL/. Pest: tests/Feature/Api/GraphqlCollectionApiTest.php.
M2M junction metadata
Many-to-many values are objects (breaking change from bare ints):
{ "related_item_id": 12, "meta": { "sort": 1 } }
Bare ints are still accepted on write and normalized to { related_item_id, meta: {} }. Optional settings.junction_fields on the field defines the meta mini-schema (admin field settings → Junction fields JSON). See Field types and Collection items.
Advanced list filters
Same dialect as admin items index / GraphQL filter arg — see Collections API. Example: ?filter[status][_eq]=published. Bruno: externa-bruno List Items Advanced Filter.