REST discovery

The Content API exposes one REST surface per panel resource. There is no per-model controller to register, and no route file to maintain. Add a Filament resource to the panel and it shows up on /api/v1 with its own abilities and webhook events.

How discovery works

On boot, ContentApi::discoverFromPanels() walks every panel that has the content-api plugin and reads $panel->getResources(). For each resource it builds a DiscoveredResource keyed by the resource slug basename — the same prefix used in the URL and in ability strings ({prefix}:create|read|update|delete).

flowchart TD
    A["Panel resources<br/>DocResource, PostResource"] --> B["ContentApi::discover"]
    B --> C["/api/v1/{prefix} routes"]
    C --> D["content.api auth middleware"]
    D --> E["Ability check"]
    E --> F["Gate::authorize policy"]
    F --> G["CRUD on model"]
    G --> H["nested seo morph"]

Endpoint shape

One route group under api/v1, registered once in the service provider:

Method Path Action
GET /api/v1/{prefix} List (paginated 20, eager-loads seo)
POST /api/v1/{prefix} Create
GET /api/v1/{prefix}/{id} Show
PATCH /api/v1/{prefix}/{id} Update
DELETE /api/v1/{prefix}/{id} Delete

{prefix} is the resource slug basename. On this docs product that gives /api/v1/docs (DocResource) and /api/v1/posts (PostResource). Every request runs the content.api middleware (Bearer key, env API_KEY, or Passport PAT), then content.api.can for the matching ability, then Gate::authorize against the model policy — a key cannot outrun Shield.

Request body

JSON with the resource's fillable fields. The controller validates a common shape (title, slug, body/content, published, parent_id) and writes only fields the model actually isFillable. A nested seo object is stored on the morph when the model HasSeo, so API-published docs and posts get SEO without a second call.

{
  "title": "Local development",
  "slug": "local-development",
  "body": "## Start\n\n```bash\ndocker compose up -d\n```",
  "published": true,
  "parent_id": 12,
  "seo": {
    "title": "Local development | Qcentic Edge",
    "description": "Compose dev stack: ports, first admin, Pest, repo layout.",
    "canonical_url": "https://edge.qcentic.com/docs/getting-started/local-development"
  }
}

This is the exact payload publish.py sends to POST /api/v1/docs (then a PATCH to force the seo morph to load for the operator). seo.title is capped at 70 chars, seo.description at 160. published: true sets published_at via PublishStatus::publishedAt().

Opt-out and manual registration

Discovery skips UserResource, RoleResource (Shield), MediaResource, and any resource whose model is Authenticatable or that defines shouldRegisterWithContentApi(): false. Add to the skip list with ContentApi::except([...]).

For a model with no Filament resource that you still want on the API, register it by model class — no resource class needed:

ContentApi::resource(\App\Models\Newsletter\Subscriber::class, 'subscribers');

That produces /api/v1/subscribers with the same CRUD and abilities.

See also

Built by Qcentic