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

SurfaceWhat it controlsHow
Admin UI (/settings/roles, /users, …)Who can use the backofficeSpatie PermissionEnum + EffectivePermissionResolver
Public CMS API (/api/v1/...)Who can create/read/update/delete collection items and files over HTTPTables 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)
ActorHow you authenticatePermissions come from
Anonymous / publicNo Authorization headerSystem role public
API keyAuthorization: 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.

  1. Sign in as an admin who can edit roles (can-edit-roles).
  2. Open Roles → edit the public role (system role; cannot be deleted, renamed, or assigned to users).
  3. In Collection access, set Read to allowed (✓) for the collections you want public. Leave Create / Update / Delete as denied (✕).
  4. 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.
  5. Save.
  6. 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'
  1. 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) from RoleSeeder
  • Tables collection_permissions, file_permissions, and api_keys
  • Spatie permissions can-show-api-keys / can-manage-api-keys (via permissions:sync)
php artisan migrate
php artisan permissions:sync
php artisan db:seed --class=RoleSeeder   # if public role is missing

2. Configure anonymous (public) access

StepWhereWhat
Edit roleAdmin → RolespublicCollection access and Files access are editable (no Spatie permission checkboxes)
Collection matrixPer collection columns Create / Read / Update / Delete✓ = allowed, ✕ = denied
Files rowGlobal Create / Read / Read private / Update / DeleteApplies to all /api/v1/files*; read_private unlocks files with effective visibility private
SavePersist collection_permissions / file_permissionsGuard 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:

  1. Roles → Create a normal role (e.g. api-website, api-partner).
  2. You may leave Spatie admin permissions empty — they do not affect /api/v1.
  3. Set Collection access for the collections/actions that key should have.
  4. Set Files access if the key should list/upload/stream files.
  5. Save.

4. Issue an API key

StepDetail
UISettings → Access → API Keys (/settings/api-keys)
PermissionsView list: can-show-api-keys. Create/revoke: can-manage-api-keys
FieldsName, role (not super-admin), optional IP allowlist, optional rate limit / expiry
SecretShown 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.test or staging)
  • Paths always under /api/v1/...
  • Clone externa-bruno: open the folder in Bruno, env Local (or a copy for staging), .envEXTERNA_API_KEY, then set collection_slug / item_id / file_id on 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 ApiAccess with actor = public

Bearer token → API key

  1. Hash the secret and look up api_keys
  2. Reject if missing, revoked, or expired → 401
  3. Enforce optional IP allowlist401 if client IP not listed
  4. Enforce rate limit (key override or API_KEY_RATE_LIMIT, default 60/min) → 429
  5. Reject if linked role is super-admin401
  6. Bind ApiAccess with actor = api_key and that role
  7. Touch last_used_at

Route group also uses Laravel throttle:api for coarse traffic shaping.

Collection access matrix

Stored in collection_permissions:

ColumnMeaning
role_idSpatie/roles id (public or custom)
collection_idTarget collection
actioncreate | read | update | delete
allowedMust 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):

ColumnMeaning
role_idSpatie/roles id (public or custom)
actioncreate | read | read_private | update | delete
allowedMust 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 grantsPublic filePrivate file (effective)
no readFiles API 403; item field { "id", "access": "denied" }Files API 403; item field null / omitted (no id leak)
read onlyList / show / content / full expandFiltered from list; show/content 403; item field null / omitted
read + read_privateSame as publicFull 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

MethodPathRequired actionNotes
GET/api/v1/collections(implicit read)Returns only collections where the actor has read
GET/api/v1/collections/{slug}readIncludes 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

MethodPathRequired actionSuccess
GET/api/v1/collections/{slug}/itemsread200 + data + pagination meta
GET/api/v1/collections/{slug}/items/{id}read200
POST/api/v1/collections/{slug}/itemscreate201 — body { "data": { …field values } }
PATCH/api/v1/collections/{slug}/items/{id}update200 — body { "data": { … } }
DELETE/api/v1/collections/{slug}/items/{id}delete204

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

MethodPathRequired actionNotes
GET/api/v1/filesreadList: 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}/contentread (+ 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/filescreateMultipart upload (file)
PATCH/api/v1/files/{id}updateMetadata / focal / rename
DELETE/api/v1/files/{id}deleteSoft 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_private403.

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 / valueEffect
filesExpand all image / file / files fields (and file slots inside blocks) via FileFieldExpander
usersPopulate user_created / user_updated mini-objects (otherwise those keys are null; user_*_id always present)
field nameFor 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 links
  • user_created / user_updated: null

With include

includeResult
filesFile 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_fieldEach 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 from files arrays — no id or url
  • Soft-deleted / missing files → null (or omitted from files arrays)

`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)

ParamUsed onPurpose
page, per_pagelistPagination (per_page clamped 1–100, default 15)
filter[...]listField filters (same idea as admin item query service)
localelist / showLocale for flattened translatable fields. Must be an enabled project content locale; otherwise 422
include_all_translationslist / showWhen true, include enabled locales only in data (orphans for disabled locales are omitted from the response but kept in DB)
includelist / showCSV opt-in expand: files, users, and/or relation field names (depth 1). See Expand with include

Permission → behaviour cheat sheet

Grants on roleList collectionsGet collection / itemsCreateUpdateDelete
(none)[] empty list403403403403
read onlyincludes collection200403403403
read + createyesyes201403403
read + updateyesyes403200403
read + deleteyesyes403403204
all fourfull CRUDyesyesyesyes

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

StatusTypical cause
401Bad/revoked/expired key, IP not allowlisted, key on super-admin
403Role 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
404Unknown collection slug or item id
422Validation failed on create/update payload
429API key rate limit exceeded
503public role missing from DB

CORS and config

ItemDetail
Routesroutes/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 gateNon-empty allowlist → EnforcePublicApiOrigin on /api/v1/* and /api/graphql (browser Origin must match; no Origin → valid API key required)
Rate defaultconfig/api.phpAPI_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

ScreenPathPurpose
Role edit → Files access/settings/roles/{id}/editGlobal file API create/read/read_private/update/delete
Role edit → Collection access/settings/roles/{id}/editGrant/deny create/read/update/delete per collection
API Keys/settings/api-keysCreate keys (secret once), revoke
File manager search/filesSearch is server-side only (GET /files/list?search=); load-more stays enabled while searching
File pickerdrawerSame 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.

  1. Install Bruno, clone / open the externa-bruno folder as a collection
  2. Select environment Local (environments/Local.bru; default base_url = http://externa-core.test)
  3. Copy .env.sample.env, set EXTERNA_API_KEY (and EXTERNA_WEBHOOK_SECRET if needed)
  4. Set request vars collection_slug / item_id / file_id as needed
  5. Staging / remote: duplicate Local in Bruno, set base_url (no trailing slash) and the matching key in .env
  6. Folder PublicApi inherits Bearer auth from folder.bru (except List Collections Anonymous, which uses auth: none)
  7. If Allowed origins is set in project settings, Bruno calls without an Origin header require a valid api_key (anonymous requests will 403)
  8. 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

ConcernLocation
Routesroutes/api.php
Auth middlewareapp/Http/Middleware/ResolveApiAccess.php
Origin / CORSEnforcePublicApiOrigin, ConfigurePublicApiCors, Support\Api\PublicApiOrigin
Guard / syncCollectionPermissionGuard / CollectionPermissionSync, FilePermissionGuard / FilePermissionSync
Controllersapp/Http/Controllers/Api/V1/*
ModelsApiKey, CollectionPermission, FilePermission, Role (isPublic, system flags)
Admin API keysapp/Http/Controllers/Admin/ApiKeyController.php
Configconfig/api.php, config/cors.php; project key public_api_allowed_origins
Brunogithub.com/qiick-io/externa-bruno
Pesttests/Feature/Api/PublicApiOriginGateTest.php

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" }]
  }
}
RuleEffect
Empty fields mapAll fields allowed (backward compatible)
fields.*.read: falseStripped from list/show responses
fields.*.create / update: falseRejected on write (403/422 path via guard)
item_filterHides 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.

Previous
Collections API