area17/twill-api
Twill API adds a RESTful JSON API layer to Twill, Area 17's Laravel CMS. It exposes configurable endpoints for Twill models with authentication, filtering, pagination, and relationships, letting content managed in Twill be consumed by SPAs, mobile apps, or other services.
area17/twill-api is a thin, read-only JSON:API layer over Twill models. It does not replace Twill's admin — it gives your front end (Next/Nuxt/Vue) a clean, spec-compliant read surface over the content editors already manage.
Install and publish
composer require area17/twill-api
php artisan vendor:publish --provider="Area17\TwillApi\TwillApiServiceProvider"
This drops a config file (typically config/twill-api.php) containing the route prefix, middleware stack, locale handling, and the model → resource map. Confirm what actually got registered:
php artisan route:list | grep api
php artisan vendor:publish --provider="Area17\TwillApi\TwillApiServiceProvider" --tag=config
Expose your first model
prefix to api/v1 and middleware to ['api'] (add auth:sanctum later if the content isn't public).php artisan list twill to see the exact twill:make:*api* signature in your installed version. It generates a schema/resource plus a repository under app/JsonApi/<Model>/.GET /api/v1/pages and confirm the envelope: data[].type, data[].id, data[].attributes, data[].relationships, plus top-level links and meta.Where to look first
config/twill-api.php — prefix, middleware, locale, resource map.app/JsonApi/<Model>/ directory — this is where 90% of your work happens.vendor/area17/twill-api/src — the base repository and base resource/schema classes you will subclass; read them before overriding anything, since the package is small and the base classes are the real documentation.php artisan route:list --path=api — the fastest way to see what endpoints and parameter names the installed version actually supports.First real use case: a marketing site's page-by-page fetch — GET /api/v1/pages?filter[slug]=about&include=blocks,cover&fields[pages]=title,seo returning one JSON:API document the front end can render without a second round trip.
Pair a schema with a repository. The schema declares the JSON:API contract (attributes, relationships, filters, sortables, pagination, sparse fieldsets); the repository owns the query. Keep query concerns in the repository — eager loads, published scoping, ordering — and keep the schema declarative. When a front end asks for a new filter, the change should be a repository method plus one line of schema registration, not a new controller.
// app/JsonApi/Pages/PageRepository.php
protected function baseQuery()
{
return parent::baseQuery()
->published() // Twill's published scope
->with(['blocks', 'cover']) // kill N+1 for common includes
->orderBy('publish_start_date', 'desc');
}
Declare only what you support. Add filters and sortables explicitly (filter[slug], filter[tag], sort=-publish_start_date). Undeclared parameters should 400 rather than silently no-op — that's a feature, not a bug, and it keeps your API contract honest as the front end grows.
Translations need a decision, not a default. Twill content is translatable; the API resolves attributes per locale, usually via Accept-Language or a ?locale= query parameter. Pick one (header is more cache-friendly with a Vary: Accept-Language), document the fallback chain, and make sure your CDN cache key includes the resolved locale. Never mix locales in one response document.
Relationships and includes. Model Twill's browsers, media, and block relations as JSON:API relationships and let clients opt in with ?include=blocks.images. Support nested includes (blocks.media) but cap depth — a client asking for include=a.b.c.d on a polymorphic block tree can generate an enormous query. Prefer allowing two levels and documenting the canonical front-end query.
Media and images. Don't leak storage paths. Return role-based URLs from Twill's image crops (cover, thumbnail) in an attribute or a dedicated media resource; if the disk is private, generate signed/temporary URLs at serialization time rather than storing them.
Custom endpoints. For anything outside plain resource reads (search, aggregated navigation, sitemap), add a route in routes/api.php pointing at a controller that extends the package's base controller. You keep the JSON:API error/serialization behavior and only write the query.
Caching is the whole point. A read-only API over CMS content is the easiest caching win in the stack: cache the rendered response keyed on URL + locale + Accept, and bust on save via Twill model events (saved, deleted) using cache tags or a versioned key prefix. Because there are no writes through the API, invalidation is entirely under your control.
Testing. Use Laravel's getJson() against the real routes — assert data.0.type, attribute keys, include resolution, and 4xx behavior for bad filters. These tests double as your API contract for the front end.
composer.json, and read the base classes in vendor/ rather than trusting blog posts or the README alone.Accept: application/vnd.api+json; a plain Accept: application/json can yield a 406 from the underlying JSON:API server. Test with curl before blaming the front end.published() may not mean "live". Twill supports scheduled publishing (publish_start_date / publish_end_date). Confirm whether your installed version's scope respects dates, or add the window to baseQuery() yourself — otherwise embargoed pages leak.?page[size]=10000 will otherwise happily materialize your entire table with all includes.baseQuery() or explicitly handled in the schema's relationship resolution.Vary header explicitly.APP_URL and the storage disk config; changing either silently breaks every cached response. Generate absolute URLs at serialization time and re-warm caches after config changes.api middleware group doesn't grant CORS. Configure config/cors.php for the front-end origin, including Accept and Accept-Language in allowed_headers, and credentials only for authenticated endpoints.source.parameter or detail; log the full error document in dev. If a relationship include returns empty, the inverse isn't declared — check both sides of the schema, not just the query.How can I help you explore Laravel packages today?