antymoro / twigflow
A PHP application with customizable modules and templates, supporting Payload and Sanity CMSes.
Requires
- php: ^7.4 || ^8.0
- guzzlehttp/guzzle: ^7.9
- guzzlehttp/promises: ^2.0
- monolog/monolog: ^3.8
- php-di/php-di: ^7.0
- sanity/sanity-php: ^1.5
- slim/psr7: ^1.0
- slim/slim: ^4.0
- slim/twig-view: ^3.0
- symfony/cache: ^7.2
- symfony/console: ^5.0
- symfony/filesystem: ^5.0
- vlucas/phpdotenv: ^5.6
This package is auto-updated.
Last update: 2026-05-18 12:44:04 UTC
README
TwigFlow is a PHP rendering layer that sits between a headless CMS (Sanity or Payload) and the browser. It fetches content from the CMS, runs it through optional PHP processors, and renders it using Twig templates.
Think of it as a "glue" application: the CMS holds all your content, your Twig templates define how pages look, and TwigFlow connects them — handling routing, caching, search, and multi-language support along the way.
How a Page Request Works
Browser request: GET /en/blog/my-article
│
▼
LanguageMiddleware
- Extracts "en" from the URL
- Validates against SUPPORTED_LANGUAGES
- Redirects if language is missing or unsupported
│
▼
PageController
- Generates a cache key (language + URL)
- If cached → return HTML immediately
│
▼ (cache miss)
DataProcessor
- Asks the CMS client for the page data
- Collects async HTTP requests from module processors
- Fires all requests in parallel (Guzzle promises)
- Waits for all results
- Runs CMS-specific data transformations
- Runs any custom module/page processors
- Attaches translations and language-switcher paths
│
▼
Twig renders page.twig (or pages/{type}.twig)
│
▼
HtmlUpdater post-processes the HTML
- Inlines SVGs, injects JSON, replaces template placeholders
│
▼
Response cached, then sent to browser
Connecting to a CMS
The CMS is selected via the CMS_CLIENT environment variable. Two clients are available out of the box.
Sanity
Set CMS_CLIENT=sanity and provide:
API_URL=https://<project-id>.api.sanity.io/v2022-03-07 API_ID=<project-id> API_ENV=production # Sanity dataset name API_KEY=<sanity-token> # Optional, needed for draft content
TwigFlow talks to Sanity using GROQ queries. Examples of what it fetches:
| Purpose | GROQ query |
|---|---|
| Single page | *[_type == "page" && slug.current == "about"][0] |
| Collection item | *[_type == "blog" && slug.current == "my-post"][0] |
| References | *[_id in ["id1", "id2"]]{fields…} |
| All documents (scraper) | *[]{_type, slug, _id, _updatedAt} |
| Search index | *[_type == "scraped_documents" && content.en match "query*"] |
What gets processed automatically:
localeString/localeText— multi-language fields; TwigFlow picks the value for the active languageblockContent/localeBlockContent— Sanity's Portable Text; converted to HTMLreference— resolved by fetching the referenced document and substituting it inlinesanity.imageAsset— image URL constructed from the Sanity CDN
Reference resolution is configured in application/reference_fields.json. It tells TwigFlow which fields to fetch when it encounters a reference:
{
"fields": ["_id", "slug", "_type", "title"],
"nested_references": {
"author": {
"fields": ["name", "photo"],
"is_array": false
}
}
}
Payload CMS
Set CMS_CLIENT=payload and provide:
API_URL=https://your-payload-instance.com/api API_KEY=<api-key>
TwigFlow fetches pages via the Payload REST API:
GET /pages?where[slug][equals]=about&locale=en
Payload uses Lexical for rich text. TwigFlow parses the Lexical JSON format into HTML, handling paragraphs, headings, bold/italic/underline, lists, blockquotes, and links.
Directory Structure
twigflow/
├── index.php # Entry point
├── .env # Environment variables (create this)
├── src/ # Framework core (this package)
│ ├── CmsClients/ # Sanity and Payload implementations
│ ├── Controllers/ # Page, Search, Cache, Scraper, API
│ ├── Processors/ # Data orchestration
│ ├── Services/ # Cache, Scraper, Database
│ ├── Middleware/ # Language detection, error handling
│ ├── Utils/ # HTTP fetcher, HTML post-processor, helpers
│ ├── Context/ # Request-scoped state (language, OG tags)
│ └── Config/ # Routes and DI container
│
└── application/ # Your application code (you create this)
├── views/ # Twig templates
│ ├── page.twig # Default page template
│ ├── pages/ # Collection-specific templates
│ ├── svg/ # SVG files (inlined by HtmlUpdater)
│ └── templates/ # HTML snippets injected into pages
├── modules/ # Custom module processors (m_{type}.php)
├── pages/ # Custom page processors ({type}.php)
├── api/ # Custom API endpoints
├── routes.json # Collection URL patterns
├── globals.json # Queries fetched for every page
├── translations.json # UI strings by language
├── og_tags.json # Default Open Graph meta tags
└── cache_regeneration.json # Paths to pre-warm after cache clear
Configuration
All configuration lives in a .env file in the project root.
Required
| Variable | Description |
|---|---|
APP_ENV |
development or production |
CMS_CLIENT |
sanity or payload |
API_URL |
Base URL of your CMS API |
API_KEY |
API token for authentication |
Optional
| Variable | Default | Description |
|---|---|---|
HOMEPAGE_SLUG |
homepage |
CMS slug for the homepage |
SUPPORTED_LANGUAGES |
(none) | Comma-separated codes: en,pl,de |
DEFAULT_LANGUAGE |
first in list | Language used when none is detected |
CACHE_EXPIRE_TIME |
0 |
Cache TTL in seconds (0 = never expire) |
TWIG_CACHE |
false |
Enable compiled template caching |
CACHE_MAX_AGE |
(none) | HTTP Cache-Control: max-age in seconds |
LOG_TO_STDOUT |
false |
Log to stdout instead of rotating files (useful for Docker) |
MEASURE_PERFORMANCE |
false |
Append execution time as HTML comment |
Sanity-specific
| Variable | Description |
|---|---|
API_ID |
Sanity project ID |
API_ENV |
Sanity dataset (e.g., production) |
Database (optional, for scraper queue)
| Variable | Description |
|---|---|
DB_HOST |
MySQL host |
DB_NAME |
Database name |
DB_USERNAME |
MySQL user |
DB_PASSWORD |
MySQL password |
Routing
Routes are defined in src/Config/routes.php. Built-in routes:
| Method | Path | Handler |
|---|---|---|
| GET | / |
Homepage |
| GET | /{slug} |
Any page by slug |
| GET | /api/search |
Full-text search |
| GET | /api/live-search |
Search (autocomplete variant) |
| GET/POST | /api/{endpoint} |
Custom API endpoints |
| GET | /api/clear-cache |
Purge all caches |
| GET | /api/scraper/init |
Build scraper job queue |
| GET | /api/scraper/process |
Process scraper jobs |
| GET | /api/scraper/prune |
Remove stale search entries |
Collection routes are defined in application/routes.json:
{
"/blog/{slug}": {
"collection": "blog",
"page": "blog"
}
}
This maps /blog/my-post to the blog collection in the CMS, rendered with application/views/pages/blog.twig.
Language routing is automatic. If SUPPORTED_LANGUAGES=en,pl, then /en/about and /pl/about both resolve to the about page with the appropriate language active.
The Module System
Pages in the CMS are built from modules — named blocks of content. TwigFlow processes each module and makes the data available to Twig.
For simple modules (static content), no PHP code is needed. For modules that require extra data (e.g., a "Latest Articles" module that needs to fetch recent posts), you create a processor in application/modules/m_{type}.php:
// application/modules/m_news_list.php namespace App\Modules; use App\Modules\BaseModule; use App\Modules\ModuleProcessorInterface; class m_news_list extends BaseModule implements ModuleProcessorInterface { public function fetchData(array $module, array $data): array { // Return async HTTP requests; they run in parallel with other modules return [ 'articles' => $this->apiFetcher->asyncFetchFromApi( '*[_type == "article"] | order(publishedAt desc)[0..5]' ) ]; } public function process(array $module, array $asyncData): array { $module['articles'] = $asyncData['articles'] ?? []; return $module; } }
The fetchData() return values are fired as parallel HTTP requests. Once all requests settle, process() receives the results. This keeps page load times low even when a page has many data-hungry modules.
Global Data
Data you need on every page (navigation, footer, site settings) is defined in application/globals.json:
{
"navigation": {
"query": "*[_type == 'navigation'][0]"
},
"footer": {
"query": "*[_type == 'footer'][0]"
}
}
These queries run alongside module queries and are available in every Twig template as {{ globals.navigation }}.
Templates and the HtmlUpdater
Templates live in application/views/. The default template for all pages is page.twig. Collection-specific templates go in pages/ (e.g., pages/blog.twig).
Every template receives this data:
metadata — CMS page fields (title, slug, SEO settings, etc.)
modules — Array of processed modules
globals — Global data (nav, footer, etc.)
home_url — Language-aware home URL
translations — UI strings from translations.json
paths — URLs for each language (used by language switcher)
isAjax — True if request has X-Requested-With header
After Twig renders, HtmlUpdater scans the HTML for special placeholders:
| Placeholder | Result |
|---|---|
[[templates]] |
Injects all files from views/templates/ as <script type="text/template"> tags |
[[svg::name]] |
Inline SVG from views/svg/name.svg |
[[sprite::name]] |
SVG sprite <use> reference |
[[json::name]] |
Contents of src/json/name.json |
[[get::param]] |
Value of GET parameter param |
Search
TwigFlow includes a search system built on top of Sanity's scraped_documents collection.
How content gets indexed
GET /api/scraper/init— Compares all CMS documents against the existing search index and creates a job for each new or updated document.GET /api/scraper/process— Processes pending jobs: renders each page, extracts text from the<article>element (skipping elements with classno-search), and saves the cleaned text to Sanity as ascraped_documentsrecord.GET /api/scraper/prune— Deletes search index entries for documents that no longer exist in the CMS.
Search endpoints
Full search — GET /api/search?q=query
Returns results with highlighted snippets, sorted by relevance (title matches first).
Live search — GET /api/live-search?q=query
Returns fewer results with smaller snippets; designed for autocomplete.
Response format:
[
{
"title": "My Article",
"url": "/en/blog/my-article",
"type": "blog",
"content": ["...text with <strong>query</strong> highlighted..."]
}
]
To exclude content from search indexing, add class="no-search" to any HTML element in your templates.
Caching
TwigFlow has three caching layers:
| Layer | What it caches | Controlled by |
|---|---|---|
| API response cache | Raw JSON from CMS queries | CACHE_EXPIRE_TIME env var |
| Page cache | Fully rendered HTML per URL + language | CACHE_EXPIRE_TIME env var |
| Twig template cache | Compiled PHP from .twig files |
TWIG_CACHE env var |
Caching is automatically disabled when APP_ENV=development.
To bypass the cache on a single request, add ?disable_cache=true to the URL.
To clear all caches (and optionally pre-warm specific paths), call GET /api/clear-cache. Paths to pre-warm are listed in application/cache_regeneration.json.
Multi-Language Support
Set SUPPORTED_LANGUAGES=en,pl,de to enable multilanguage routing.
- Language is detected from the URL prefix (
/en/,/pl/), then from the session, then from theAccept-Languageheader, then fromDEFAULT_LANGUAGE. - Sanity
localeStringfields are automatically resolved to the active language. - Bots (Googlebot, Bingbot, etc.) receive 301 redirects; regular users get 302 redirects to avoid locking the browser's back button.
- Every page render includes a
pathsvariable with the current page's URL in each supported language, for use in a language switcher.
Custom API Endpoints
Drop a PHP file into application/api/{method}/{endpoint}.php and TwigFlow will route GET/POST /api/{endpoint} to it automatically:
// application/api/get/featured-posts.php namespace App\Api; class featured_posts { public function process(array $params): array { // Return data; TwigFlow wraps it in {"status":"success","data":{...}} return ['posts' => []]; } }
Installation
TwigFlow is a Composer package. The recommended way to start a new project is with the boilerplate, which gives you the application/ directory scaffold and a working index.php entry point:
-
Clone the boilerplate:
git clone https://github.com/antymoro/twigflow-boilerplate.git cd twigflow-boilerplate -
Install TwigFlow and its dependencies via Composer:
composer require antymoro/twigflow
-
Create a
.envfile in the project root and fill in your CMS credentials (see Configuration above). -
Point your web server document root at the project root. The included
.htaccessroutes all requests throughindex.php.
Requirements: PHP 8.2+, Composer, a web server with mod_rewrite (Apache) or equivalent.
If you are adding TwigFlow to an existing project rather than using the boilerplate, require the package the same way (
composer require antymoro/twigflow) and then create theapplication/directory structure manually as described in Directory Structure above.