PHPackages                             antymoro/twigflow - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Templating &amp; Views](/categories/templating)
4. /
5. antymoro/twigflow

ActiveLibrary[Templating &amp; Views](/categories/templating)

antymoro/twigflow
=================

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

v1.1.0(4mo ago)0512MITPHPPHP ^7.4 || ^8.0CI failing

Since Apr 11Pushed 1mo ago1 watchersCompare

[ Source](https://github.com/daniel-wozniak/twigflow)[ Packagist](https://packagist.org/packages/antymoro/twigflow)[ RSS](/packages/antymoro-twigflow/feed)WikiDiscussions main Synced today

READMEChangelogDependencies (24)Versions (5)Used By (0)

TwigFlow
========

[](#twigflow)

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
------------------------

[](#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
-------------------

[](#connecting-to-a-cms)

The CMS is selected via the `CMS_CLIENT` environment variable. Two clients are available out of the box.

### Sanity

[](#sanity)

Set `CMS_CLIENT=sanity` and provide:

```
API_URL=https://.api.sanity.io/v2022-03-07
API_ID=
API_ENV=production        # Sanity dataset name
API_KEY=    # Optional, needed for draft content
```

TwigFlow talks to Sanity using **GROQ** queries. Examples of what it fetches:

PurposeGROQ querySingle 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

[](#payload-cms)

Set `CMS_CLIENT=payload` and provide:

```
API_URL=https://your-payload-instance.com/api
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
-------------------

[](#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
-------------

[](#configuration)

All configuration lives in a `.env` file in the project root.

### Required

[](#required)

VariableDescription`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

[](#optional)

VariableDefaultDescription`HOMEPAGE_SLUG``homepage`CMS slug for the homepage`SUPPORTED_LANGUAGES`*(none)*Comma-separated codes: `en,pl,de``DEFAULT_LANGUAGE`first in listLanguage 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

[](#sanity-specific)

VariableDescription`API_ID`Sanity project ID`API_ENV`Sanity dataset (e.g., `production`)### Database (optional, for scraper queue)

[](#database-optional-for-scraper-queue)

VariableDescription`DB_HOST`MySQL host`DB_NAME`Database name`DB_USERNAME`MySQL user`DB_PASSWORD`MySQL password---

Routing
-------

[](#routing)

Routes are defined in `src/Config/routes.php`. Built-in routes:

MethodPathHandlerGET`/`HomepageGET`/{slug}`Any page by slugGET`/api/search`Full-text searchGET`/api/live-search`Search (autocomplete variant)GET/POST`/api/{endpoint}`Custom API endpointsGET`/api/clear-cache`Purge all cachesGET`/api/scraper/init`Build scraper job queueGET`/api/scraper/process`Process scraper jobsGET`/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
-----------------

[](#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
-----------

[](#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-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:

PlaceholderResult`[[templates]]`Injects all files from `views/templates/` as `` tags`[[svg::name]]`Inline SVG from `views/svg/name.svg``[[sprite::name]]`SVG sprite `` reference`[[json::name]]`Contents of `src/json/name.json``[[get::param]]`Value of GET parameter `param`---

Search
------

[](#search)

TwigFlow includes a search system built on top of Sanity's `scraped_documents` collection.

### How content gets indexed

[](#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 `` 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

[](#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 query highlighted..."]
  }
]
```

To exclude content from search indexing, add `class="no-search"` to any HTML element in your templates.

---

Caching
-------

[](#caching)

TwigFlow has three caching layers:

LayerWhat it cachesControlled 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 varCaching 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
----------------------

[](#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
--------------------

[](#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
------------

[](#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](#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](#directory-structure) above.

###  Health Score

41

—

FairBetter than 87% of packages

Maintenance83

Actively maintained with recent releases

Popularity15

Limited adoption so far

Community10

Small or concentrated contributor base

Maturity46

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 50.1% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~95 days

Total

3

Last Release

144d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/e8dece438cb3ec9eebf584369fbd66099b98b4c4707a92a7edb494b777ae4b0b?d=identicon)[antymoro](/maintainers/antymoro)

---

Top Contributors

[![daniel-wozniak](https://avatars.githubusercontent.com/u/43903834?v=4)](https://github.com/daniel-wozniak "daniel-wozniak (189 commits)")[![antymoro](https://avatars.githubusercontent.com/u/43903834?v=4)](https://github.com/antymoro "antymoro (187 commits)")[![gregmatys](https://avatars.githubusercontent.com/u/2726982?v=4)](https://github.com/gregmatys "gregmatys (1 commits)")

### Embed Badge

![Health badge](/badges/antymoro-twigflow/health.svg)

```
[![Health](https://phpackages.com/badges/antymoro-twigflow/health.svg)](https://phpackages.com/packages/antymoro-twigflow)
```

###  Alternatives

[tempest/framework

The PHP framework that gets out of your way.

2.2k34.4k15](/packages/tempest-framework)[laravel/framework

The Laravel Framework.

34.8k543.8M20.1k](/packages/laravel-framework)[aws/aws-sdk-php

AWS SDK for PHP - Use Amazon Web Services in your PHP project

6.3k543.5M2.6k](/packages/aws-aws-sdk-php)[matomo/matomo

Matomo is the leading Free/Libre open analytics platform

21.7k38.9k](/packages/matomo-matomo)[shopware/core

Shopware platform is the core for all Shopware ecommerce products.

585.6M574](/packages/shopware-core)[craftcms/cms

Craft CMS

3.6k3.6M3.1k](/packages/craftcms-cms)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
