Snippet

Editor-owned reusable content fragments and dev-registered components, included in many pages, edited from the admin and as flat files, translated per host —without the developer touching a Twig template for each one.

It unifies two things that used to be separate:

  • Content snippets — reusable content owned by the editor (a CTA text, an author box, a footer note). Stored as Markdown, one row per host/locale.
  • Component snippets — reusable components owned by the developer (a styled call-to-action, a value-props grid). Declared once in PHP with a parameter schema and a Twig template; the block editor builds the form for free.

Both are invoked the same way, from page content:

{{ snippet('footer-note') }}
{{ snippet('cta', { title: 'Ready to start?', buttonText: 'Contact us', buttonUrl: '/contact' }) }}

Install

composer require pushword/snippet

Register the bundle (done automatically by Symfony Flex):

// config/bundles.php
Pushword\Snippet\PushwordSnippetBundle::class => ['all' => true],

Create the table:

php bin/console doctrine:schema:update --force

The snippet() Twig function

{{ snippet(name, params = {}) }}

Resolution order:

  1. A dev-registered component snippet named name (see below).
  2. Otherwise, a content snippet whose slug is name, for the current host.
  3. Otherwise, a global content snippet of that slug (the "All hosts" fallback).
  4. Otherwise, an empty string.

params is an optional map. For component snippets it is passed to the template(and validated against the schema in the editor). For content snippets it is exposed to the snippet's own Twig as params and as top-level variables.

Content snippets

A content snippet is a Snippet entity: a slug (the reference key, unique per host), a name (admin label), Markdown content, optional tags, and a custom property bag. It is rendered through the same filter pipeline as a page(Twig → Markdown → multisite links → ShowMore…), so everything you can write in a page works in a snippet. In the admin the content field uses the same block editor as a page (EditorJS with a Markdown/Monaco toggle) whenpushword/admin-block-editor is installed.

Host and "All hosts"

The host picker is a dropdown of your configured hosts plus an All hostsoption. Pick a host to scope the snippet to one site; pick All hosts (stored as an empty host) to make it a global fallback used on every host. A host-specific snippet of the same slug always overrides its global twin. Leaving a host unset on the old free-text field meant the snippet rendered nowhere — the dropdown removes that footgun. The slug is auto-filled from the name on creation while left empty.

Global ("All hosts") snippets sync too: they live outside any host folder, in the base {content}/pw-snippets/ directory (host-scoped ones stay under{content}/{host}/pw-snippets/). The global directory is synced once per run,during the default app's primary host pass.

Manage them from the admin (Snippets in the menu) or as flat files viapushword/flat (see Flat-file sync).

<!-- {flat_content_dir}/pw-snippets/footer-note.md -->

---

name: Footer note
tags:
  - global

---

Need a hand? [Contact us](/contact) — we usually reply within a day.

The filename is the slug and the host comes from the content directory, so neither is repeated in the frontmatter; any extra key becomes a custom property.

Use it:

{{ snippet('footer-note') }}

Content snippets accept params too:

# {{ params.heading|default('Welcome') }}
{{ snippet('greeting', { heading: 'Hello there' }) }}

Component snippets

A component snippet replaces the bespoke custom Twig functions sites used to write by hand. Declare a class with #[AsSnippet], a parameter schema, and a Twig template:

namespace App\Snippet;

use Pushword\Snippet\Attribute\AsSnippet;
use Pushword\Snippet\Component\AbstractSnippetComponent;

#[AsSnippet(name: 'cta', template: 'snippet/cta.html.twig', label: 'Call to action')]
final class CtaSnippet extends AbstractSnippetComponent
{
    public function getSchema(): array
    {
        return [
            'title' => ['type' => 'string', 'label' => 'Title'],
            'description' => ['type' => 'text', 'label' => 'Description'],
            'buttonText' => ['type' => 'string', 'label' => 'Button label'],
            'buttonUrl' => ['type' => 'string', 'label' => 'Button URL'],
        ];
    }

    // optional: apply defaults / cast types before rendering
    public function prepareParams(array $params): array
    {
        return $params + ['buttonUrl' => '#'];
    }
}
{# templates/snippet/cta.html.twig #}
<div class="cta">
  {% if params.title %}<h2>{{ params.title }}</h2>{% endif %}
  {% if params.description %}<p>{{ params.description }}</p>{% endif %}
  {% if params.buttonText %}<a href="{{ params.buttonUrl }}">{{ params.buttonText }}</a>{% endif %}
</div>

The template receives params (the prepared map), each param as a top-level variable, and the current page.

Schema field types

The schema drives both rendering and the block-editor form. Supported types:

TypeEditor controlStored value
stringsingle-line inputstring
texttextareastring
boolcheckboxboolean
selectdropdown (options list)string
mediamedia pickerfilename string
collectionrepeatable rows (fields)list of objects

collection example (the recurring {icon, title, text} card pattern):

'items' => ['type' => 'collection', 'label' => 'Cards', 'fields' => [
    'icon' => ['type' => 'string'],
    'title' => ['type' => 'string'],
    'text' => ['type' => 'text'],
]],

Block editor

When pushword/admin-block-editor is installed, a Snippet block is added to the editor toolbar automatically. It lists every snippet available for the page's host (components and content snippets), and builds a form from the selected component's schema. Content snippets (no schema) expose a free-form JSON params field.

The block round-trips to a snippet('name', {params}) call in Markdown, the params serialised as a JSON object argument:

{{ snippet('cta', {"title":"Ready to start?","buttonText":"Contact us","buttonUrl":"/contact"}) }}

Only a standalone snippet call becomes a block; inline {{ snippet(...) }}mentions inside a paragraph stay in the prose. The integration is contributed through EditorJsToolProviderInterface, so the block editor stays unaware of snippets and the block disappears cleanly when pushword/snippet is absent.

Writing snippet calls by hand

You can always insert snippet calls directly in Markdown — exactly like the custom Twig functions they replace:

{{ snippet('cta', { title: 'Ready to start?', buttonText: 'Contact us', buttonUrl: '/contact' }) }}

The params map accepts inline Twig ({ title: '…' }) or JSON({"title":"…"}) — both round-trip cleanly through flat files and the editor(single-quoted/JSON5-ish objects are repaired on import).

Flat-file sync

With pushword/flat installed, content snippets sync to and from{flat_content_dir}/pw-snippets/{slug}.md (one Markdown file per snippet, YAML frontmatter for name, tags and custom properties). The pw- prefix keeps the directory from clashing with a real page tree — the page importer skips it:

php bin/console pw:flat:sync --entity=snippet            # auto-detect direction
php bin/console pw:flat:sync --entity=snippet -m export  # DB → files
php bin/console pw:flat:sync --entity=snippet -m import  # files → DB

Global ("All hosts") snippets have no host folder, so they sync to and from the base {content}/pw-snippets/ directory instead. That directory is processed once per run, during the default app's primary host pass — so a full sync (no host argument) always covers it, but targeting a single non-primary host(pw:flat:sync some-other-host) leaves globals untouched.

--entity=all (the default) includes snippets alongside pages and media.Direction is detected from file modification times against the last sync, the same way pages work. Component snippets are code, so they are versioned by git,not synced.

Versioning

When pushword/version is installed, content snippets get the same version/restore history as pages. Every persist or update writes a JSON snapshot, and the admin exposes a Versions action on the Snippet CRUD that opens the familiar list / compare / restore views. Snippet versions are stored under var/log/version/snippet/{id}/ so they never collide with page versions.Component snippets are code, so they are versioned by git instead.

Migration guide — from custom Twig functions to component snippets

Sites commonly ship bespoke Twig functions (ctaBlock(), valueProps(),reservation_box(), gpx()…): a PHP AsTwigFunction plus a template, with the editor hand-typing the call into Markdown. Component snippets give the same output plus an editor form, validation, and discoverability — usuallyreusing the template you already have.

Take an existing function:

// Before — src/Twig/AppExtension.php
#[AsTwigFunction('ctaBlock', isSafe: ['html'])]
public function ctaBlock(string $title, string $description = '', string $buttonText = '', string $action = '#'): string
{
    return $this->twig->render('component/cta.html.twig', [
        'title' => $title, 'description' => $description,
        'buttonText' => $buttonText, 'action' => $action,
    ]);
}

1. Declare the component

Point it at the same template, listing the parameters as a schema:

// After — src/Snippet/CtaSnippet.php
#[AsSnippet(name: 'cta', template: 'component/cta.html.twig')]
final class CtaSnippet extends AbstractSnippetComponent
{
    public function getSchema(): array
    {
        return [
            'title' => ['type' => 'string'],
            'description' => ['type' => 'text'],
            'buttonText' => ['type' => 'string'],
            'action' => ['type' => 'string'],
        ];
    }

    public function prepareParams(array $params): array
    {
        return $params + ['action' => '#'];
    }
}

2. Adjust the template to read from params

The template now receives a params map instead of separate variables:

{# component/cta.html.twig #}
-<h2>{{ title }}</h2>
+<h2>{{ params.title }}</h2>

(Top-level variables still work — each param is also exposed by name — so a template using {{ title }} keeps rendering. Prefer params.title going forward.)

3. Update content calls

-{{ ctaBlock('Ready?', 'Join us today', 'Sign up', '/register') }}
+{{ snippet('cta', { title: 'Ready?', description: 'Join us today', buttonText: 'Sign up', action: '/register' }) }}

For a gradual migration, keep the old function as a one-line alias while you update content:

#[AsTwigFunction('ctaBlock', isSafe: ['html'])]
public function ctaBlock(string $title, string $description = '', string $buttonText = '', string $action = '#'): string
{
    return $this->snippetExtension->renderSnippet('cta', compact('title', 'description', 'buttonText', 'action'));
}

The collection-style functions (valueProps([{icon,title,text}, …])) map directly onto a collection schema field, so the array-of-objects you already pass in content keeps working.