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.