antymoro/twigflow

A PHP application with customizable modules and templates, supporting Payload and Sanity CMSes.

Maintainers

Package info

github.com/daniel-wozniak/twigflow

pkg:composer/antymoro/twigflow

Statistics

Installs: 496

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.0 2026-02-09 15:00 UTC

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 language
  • blockContent / localeBlockContent — Sanity's Portable Text; converted to HTML
  • reference — resolved by fetching the referenced document and substituting it inline
  • sanity.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

  1. GET /api/scraper/init — Compares all CMS documents against the existing search index and creates a job for each new or updated document.
  2. GET /api/scraper/process — Processes pending jobs: renders each page, extracts text from the <article> element (skipping elements with class no-search), and saves the cleaned text to Sanity as a scraped_documents record.
  3. GET /api/scraper/prune — Deletes search index entries for documents that no longer exist in the CMS.

Search endpoints

Full searchGET /api/search?q=query
Returns results with highlighted snippets, sorted by relevance (title matches first).

Live searchGET /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 the Accept-Language header, then from DEFAULT_LANGUAGE.
  • Sanity localeString fields 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 paths variable 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:

  1. Clone the boilerplate:

    git clone https://github.com/antymoro/twigflow-boilerplate.git
    cd twigflow-boilerplate
  2. Install TwigFlow and its dependencies via Composer:

    composer require antymoro/twigflow
  3. Create a .env file in the project root and fill in your CMS credentials (see Configuration above).

  4. Point your web server document root at the project root. The included .htaccess routes all requests through index.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 the application/ directory structure manually as described in Directory Structure above.