',
esc_url($atts['url']), // URLs
esc_html($atts['name']), // Plain text
wp_kses_post($atts['bio']) // Rich HTML
);
}
```
See [Security Guide](/guide/security) for detailed escaping guidance.
## Related
* [Guide: Shortcodes](/guide/shortcodes)
* [Guide: Security](/guide/security)
---
---
url: /foehn-framework/api/as-taxonomy.md
---
# #\[AsTaxonomy]
Register a class as a custom WordPress taxonomy.
## Signature
```php
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class AsTaxonomy
{
public function __construct(
public string $name,
public array $postTypes = [],
public ?string $singular = null,
public ?string $plural = null,
public bool $public = true,
public bool $hierarchical = false,
public bool $showInRest = true,
public bool $showAdminColumn = true,
public ?string $rewriteSlug = null,
public array $labels = [],
public array|false|null $rewrite = null,
) {}
}
```
## Parameters
| Parameter | Type | Default | Description |
| ----------------- | ----------------------- | ------- | -------------------------------------------------- |
| `name` | `string` | — | Taxonomy slug (required) |
| `postTypes` | `string[]` | `[]` | Associated post type slugs |
| `singular` | `?string` | `null` | Singular label |
| `plural` | `?string` | `null` | Plural label |
| `public` | `bool` | `true` | Whether publicly visible |
| `hierarchical` | `bool` | `false` | Hierarchical like categories |
| `showInRest` | `bool` | `true` | Enable REST API and Gutenberg |
| `showAdminColumn` | `bool` | `true` | Show column in admin post list |
| `rewriteSlug` | `?string` | `null` | Custom URL slug (shorthand for `rewrite`) |
| `labels` | `array` | `[]` | Custom labels (merged with auto-generated ones) |
| `rewrite` | `array\|false\|null` | `null` | Full rewrite config, `false` to disable, or `null` |
## Usage
### Basic Taxonomy
```php
setCapabilities([
'manage_terms' => 'manage_skills',
'edit_terms' => 'edit_skills',
'delete_terms' => 'delete_skills',
'assign_terms' => 'assign_skills',
]);
}
}
```
## Related
* [Guide: Taxonomies](/guide/taxonomies)
* [`#[AsPostType]`](./as-post-type)
---
---
url: /foehn-framework/api/as-template-controller.md
---
# #\[AsTemplateController]
Register a class as a template controller that handles full template rendering.
## Signature
```php
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class AsTemplateController
{
public function __construct(
public string|array $templates,
public int $priority = 10,
) {}
public function getTemplates(): array {}
}
```
## Parameters
| Parameter | Type | Default | Description |
| ----------- | --------------- | ------- | --------------------------------------- |
| `templates` | `string\|array` | — | Template pattern(s) to match (required) |
| `priority` | `int` | `10` | Priority for template\_include filter |
## Template Patterns
Uses WordPress template hierarchy names:
* `'single'` — Single posts
* `'single-product'` — Single product posts
* `'archive'` — Archive pages
* `'category'` — Category archives
* `'home'` — Blog home
* `'front-page'` — Static front page
* `'404'` — Not found
* `'single-*'` — Wildcard pattern
* `['home', 'front-page']` — Multiple templates
## Usage
### Basic Controller
```php
view->render('single', [
'post' => $post,
]);
}
}
```
### Return Null to Pass Through
Return `null` to let WordPress handle the template:
```php
#[AsTemplateController('single')]
final class SingleController implements TemplateControllerInterface
{
public function handle(): ?string
{
$post = \Timber\Timber::get_post();
// Only handle products
if ($post->post_type !== 'product') {
return null;
}
return $this->view->render('single-product', [
'post' => $post,
]);
}
}
```
### Multiple Templates
```php
#[AsTemplateController(['home', 'front-page'])]
final class HomeController implements TemplateControllerInterface
{
public function handle(): ?string
{
return $this->view->render('pages/home', [
'featured' => $this->getFeaturedPosts(),
]);
}
}
```
### Wildcard Pattern
```php
#[AsTemplateController('archive-*')]
final class ArchiveController implements TemplateControllerInterface
{
public function handle(): ?string
{
return $this->view->render('archive', [
'posts' => \Timber\Timber::get_posts(),
'pagination' => \Timber\Timber::get_pagination(),
]);
}
}
```
## Required Interface
Classes must implement `TemplateControllerInterface`:
```php
interface TemplateControllerInterface
{
public function handle(): ?string;
}
```
## Related
* [Guide: Template Controllers](/guide/template-controllers)
* [`TemplateControllerInterface`](./template-controller-interface)
* [`#[AsContextProvider]`](./as-context-provider)
---
---
url: /foehn-framework/api/as-timber-model.md
---
# #\[AsTimberModel]
Register a Timber class map for a post type or taxonomy without registering the type itself.
## Signature
```php
content()));
return max(1, (int) ceil($wordCount / 200));
}
/**
* Check if the post has a featured video.
*/
public function hasFeaturedVideo(): bool
{
return !empty($this->meta('featured_video_url'));
}
}
```
### Extending Pages
```php
'page',
'post_parent' => $this->ID,
'orderby' => 'menu_order',
'order' => 'ASC',
]);
}
/**
* Check if this is a parent page.
*/
public function hasChildren(): bool
{
return count($this->children()) > 0;
}
}
```
### Extending Categories
```php
meta('icon') ?: null;
}
/**
* Get the category color from ACF.
*/
public function color(): string
{
return $this->meta('color') ?: '#333333';
}
}
```
### Extending WooCommerce Products
```php
ID) ?: null;
}
/**
* Get formatted price.
*/
public function formattedPrice(): string
{
$product = $this->wcProduct();
return $product ? $product->get_price_html() : '';
}
}
```
## Usage in Templates
Once mapped, Timber automatically uses your class:
```twig
{# single.twig #}
{{ post.title }}
{{ post.readingTime }} min read
{% if post.hasFeaturedVideo %}
{# Show video player #}
{% endif %}
{{ post.content }}
```
```twig
{# archive.twig #}
{% for post in posts %}
{% set categories = post.terms('category') %}
{% for category in categories %}
{{ category.icon }} {{ category.name }}
{% endfor %}
{% endfor %}
```
## Parameters
| Parameter | Type | Required | Description |
| --------- | -------- | -------- | --------------------------------- |
| `name` | `string` | Yes | Post type or taxonomy slug to map |
## Requirements
The class must extend one of:
* `Timber\Post` for post types
* `Timber\Term` for taxonomies
## See Also
* [Guide: Post Types](/guide/post-types) — Custom post types with `#[AsPostType]`
* [Guide: Taxonomies](/guide/taxonomies) — Custom taxonomies with `#[AsTaxonomy]`
* [Timber Documentation](https://timber.github.io/docs/v2/guides/class-maps/)
---
---
url: /foehn-framework/api/as-twig-extension.md
---
# #\[AsTwigExtension]
Register a class as a Twig extension for Timber templates.
## Signature
```php
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class AsTwigExtension
{
public function __construct(
public int $priority = 10,
) {}
}
```
## Parameters
| Parameter | Type | Default | Description |
| ---------- | ----- | ------- | ------------------------------------------ |
| `priority` | `int` | `10` | Loading priority (lower values load first) |
## Requirements
The class must extend `Twig\Extension\AbstractExtension`.
## Usage
### Basic Usage
```php
"Hello, {$name}!"),
];
}
}
```
Then use it in your templates:
```twig
{{ hello('World') }} {# Output: Hello, World! #}
```
### Adding Filters
```php
{{ post.content | excerpt(200) }}
{{ post.content | reading_time }} min read
```
### With Priority
Control the order extensions are loaded:
```php
#[AsTwigExtension(priority: 5)]
final class CoreExtension extends AbstractExtension
{
// Loads before extensions with default priority (10)
}
#[AsTwigExtension(priority: 20)]
final class OverrideExtension extends AbstractExtension
{
// Loads after default priority extensions
}
```
### With Dependency Injection
Extensions are resolved through the container, so you can inject dependencies:
```php
formatter, 'format']),
];
}
}
```
## Built-in Extensions
Føhn provides built-in extensions that you can use as examples:
* `InteractivityExtension` — Helpers for WordPress Interactivity API
* `VideoEmbedExtension` — Video URL transformation utilities
## Related
* [Guide: Twig Extensions](/guide/twig-extensions)
* [Twig Documentation: Extending Twig](https://twig.symfony.com/doc/3.x/advanced.html)
---
---
url: /foehn-framework/guide/acf-blocks.md
---
# ACF Blocks
Føhn provides `#[AsAcfBlock]` for creating ACF blocks with type-safe fields using `stoutlogic/acf-builder`.
## Requirements
ACF is an optional package. Custom fields do not need it: [`#[AsPostMeta]`](/api/as-post-meta) registers a meta key with a REST schema, which is what puts a field in the block editor and makes it bindable. ACF is the better answer when the editing UI matters — repeaters, flexible content, conditional logic, media pickers.
```bash
composer require studiometa/foehn-acf
```
* [ACF Pro](https://www.advancedcustomfields.com/pro/) installed and active
* `studiometa/foehn-acf`, which brings `stoutlogic/acf-builder` with it
Nothing registers when ACF is absent: each discovery guards on the function it needs. The classes keep their `Studiometa\Foehn\` namespaces, so a project upgrading from 0.4 changes one Composer requirement and no imports.
## Basic ACF Block
```php
addText('title', ['label' => 'Title'])
->addWysiwyg('content', ['label' => 'Content'])
->addImage('background', ['label' => 'Background Image']);
}
public function compose(array $block, array $fields): array
{
return [
'title' => $fields['title'] ?? '',
'content' => $fields['content'] ?? '',
'background' => $fields['background'] ?? null,
'block_id' => $block['id'] ?? '',
];
}
public function render(array $context, bool $isPreview = false): string
{
return $this->view->render('blocks/hero', $context);
}
}
```
## Template
```twig
{# templates/blocks/hero.twig #}
{% if background %}
{% endif %}
{% if title %}
{{ title }}
{% endif %}
{% if content %}
{{ content }}
{% endif %}
```
## Field Values
`$fields` holds what [`get_fields()`](https://www.advancedcustomfields.com/resources/get_fields/) returns for the block. ACF loads the block's data before it calls the render callback, so the values are formatted and nested like the values of a post:
```php
[
'title' => 'Hello',
'show_cta' => true,
'meta' => ['label' => 'New', 'icon' => $icon], // Timber\Image
'items' => [
['heading' => 'First', 'picture' => $picture], // Timber\Image
['heading' => 'Second', 'picture' => false],
],
]
```
Do not read `$block['data']`. It is what ACF stores in the block comment: flat and unformatted (`items: 2`, `items_0_heading`, `meta_label`, image IDs, `"1"` for a true/false), or keyed by field key in a block template.
## Automatic Field Transformation
By default, Føhn automatically transforms ACF field values into Timber objects. This means you don't need to manually convert image IDs to `Timber\Image`, post IDs to `Timber\Post`, etc.
While it reads the block's fields, Føhn replaces ACF's formatting of the types below with Timber's ACF transforms, the way Timber does for `$post->meta($name, ['transform_value' => true])`. The other types keep ACF's formatting. A `get_field()` call in the block's own code still returns ACF's formatting.
### Enabled by Default
Field transformation is enabled by default. To disable it, create an ACF config file. Every field then has ACF's formatting: an image is what its `return_format` gives, an array by default.
```php
{# Gallery fields #}
{% for item in gallery %}
{% endfor %}
{# Relationship fields #}
{% for post in related_posts %}
{{ post.title }}
{% endfor %}
{# Date fields #}
```
## Full Configuration
```php
#[AsAcfBlock(
name: 'testimonial',
title: 'Testimonial',
category: 'text',
icon: 'format-quote',
description: 'Display a customer testimonial',
keywords: ['quote', 'review', 'customer'],
mode: 'preview',
supports: [
'align' => true,
'mode' => true,
'jsx' => true,
],
postTypes: ['page', 'post'],
)]
final readonly class TestimonialBlock implements AcfBlockInterface {}
```
## Complex Fields Example
```php
addText('title', ['label' => 'Section Title'])
->addTextarea('description', ['label' => 'Section Description'])
->addRepeater('features', ['label' => 'Features', 'layout' => 'block'])
->addImage('icon', ['label' => 'Icon'])
->addText('title', ['label' => 'Feature Title'])
->addTextarea('description', ['label' => 'Feature Description'])
->addLink('link', ['label' => 'Link'])
->endRepeater()
->addSelect('columns', [
'label' => 'Columns',
'choices' => [
'2' => '2 Columns',
'3' => '3 Columns',
'4' => '4 Columns',
],
'default_value' => '3',
]);
return $builder;
}
public function compose(array $block, array $fields): array
{
return [
'title' => $fields['title'] ?? '',
'description' => $fields['description'] ?? '',
'features' => $fields['features'] ?? [],
'columns' => $fields['columns'] ?? '3',
];
}
public function render(array $context, bool $isPreview = false): string
{
return $this->view->render('blocks/features', $context);
}
}
```
## Conditional Fields
```php
public static function fields(): FieldsBuilder
{
$builder = new FieldsBuilder('cta');
$builder
->addText('title')
->addSelect('button_type', [
'choices' => [
'link' => 'Link',
'download' => 'Download',
'modal' => 'Modal',
],
])
->addLink('link')
->conditional('button_type', '==', 'link')
->addFile('file')
->conditional('button_type', '==', 'download')
->addText('modal_id')
->conditional('button_type', '==', 'modal');
return $builder;
}
```
## Tabs and Groups
```php
public static function fields(): FieldsBuilder
{
$builder = new FieldsBuilder('card');
$builder
->addTab('Content')
->addText('title')
->addWysiwyg('content')
->addImage('image')
->addTab('Settings')
->addSelect('style', [
'choices' => ['default', 'featured', 'minimal'],
])
->addColorPicker('background_color')
->addTrueFalse('show_shadow');
->addTab('Link')
->addLink('link');
return $builder;
}
```
## Field Validation
Føhn provides a `ValidatesFields` trait for optional field validation and sanitization in your `compose()` method.
### Using the Trait
```php
addText('title')
->addWysiwyg('content')
->addNumber('count');
}
public function compose(array $block, array $fields): array
{
// Validate required fields (throws InvalidArgumentException if missing)
$this->validateRequired($fields, ['title']);
// Sanitize individual fields
return [
'title' => $this->sanitizeField($fields['title'], 'string'),
'content' => $this->sanitizeField($fields['content'] ?? '', 'html'),
'count' => $this->sanitizeField($fields['count'] ?? 0, 'int'),
];
}
public function render(array $context, bool $isPreview = false): string
{
return $this->view->render('blocks/hero', $context);
}
}
```
### Schema-Based Validation
For more complex validation, use `validateFields()` with a schema:
```php
public function compose(array $block, array $fields): array
{
return $this->validateFields($fields, [
'title' => ['type' => 'string', 'required' => true],
'content' => ['type' => 'html', 'default' => ''],
'count' => ['type' => 'int', 'default' => 0],
'email' => ['type' => 'email'],
'link' => ['type' => 'url'],
'items' => ['type' => 'array', 'default' => []],
]);
}
```
### Available Methods
| Method | Description |
| -------------------------------------------------- | ----------------------------------------------- |
| `validateRequired(array $fields, array $required)` | Throws if required fields are missing or empty |
| `validateType(mixed $value, string $type)` | Returns `true` if value matches expected type |
| `sanitizeField(mixed $value, string $type)` | Coerces value to expected type |
| `validateFields(array $fields, array $schema)` | Validates and sanitizes fields against a schema |
### Supported Types
| Type | Description |
| -------- | -------------------------------------------------- |
| `string` | Trimmed string |
| `int` | Integer (coerced from numeric strings) |
| `float` | Float (coerced from numeric values) |
| `bool` | Boolean (handles `'true'`, `'yes'`, `'1'`, `'on'`) |
| `array` | Array |
| `html` | HTML content (sanitized via `wp_kses_post`) |
| `email` | Email address (sanitized) |
| `url` | URL (sanitized via `esc_url_raw`) |
### Advanced Validation
For more advanced validation needs, consider using:
* [`webmozart/assert`](https://github.com/webmozarts/assert) - Simple assertions
* [`respect/validation`](https://github.com/Respect/Validation) - Fluent validation API (Zod-like)
```php
use Webmozart\Assert\Assert;
public function compose(array $block, array $fields): array
{
Assert::stringNotEmpty($fields['title'] ?? '');
Assert::nullOrInteger($fields['count'] ?? null);
return $fields;
}
```
## Preview Mode
Handle preview mode differently:
```php
public function render(array $context, bool $isPreview = false): string
{
if ($isPreview && empty($context['title'])) {
return '
Please add content
';
}
return $this->view->render('blocks/hero', $context);
}
```
## File Structure
Organize blocks with their templates:
```
app/Blocks/
├── Hero/
│ └── HeroBlock.php
├── Features/
│ └── FeaturesBlock.php
├── Testimonial/
│ └── TestimonialBlock.php
└── Cta/
└── CtaBlock.php
templates/blocks/
├── hero.twig
├── features.twig
├── testimonial.twig
└── cta.twig
```
## Attribute Parameters
| Parameter | Type | Default | Description |
| ------------- | ---------- | ----------- | ---------------------------------- |
| `name` | `string` | *required* | Block name (without `acf/` prefix) |
| `title` | `string` | *required* | Display title |
| `category` | `string` | `'widgets'` | Block category |
| `icon` | `?string` | `null` | Dashicon or SVG |
| `description` | `?string` | `null` | Block description |
| `keywords` | `string[]` | `[]` | Search keywords |
| `mode` | `string` | `'preview'` | `'preview'`, `'edit'`, or `'auto'` |
| `supports` | `array` | `[]` | Block supports |
| `template` | `?string` | `null` | Custom template path |
| `postTypes` | `string[]` | `[]` | Allowed post types |
| `parent` | `?string` | `null` | Parent block name |
## See Also
* [Native Blocks](./native-blocks)
* [Block Patterns](./block-patterns)
* [API Reference: #\[AsAcfBlock\]](/api/as-acf-block)
* [API Reference: AcfBlockInterface](/api/acf-block-interface)
---
---
url: /foehn-framework/guide/field-fragments.md
---
# ACF Field Fragments
::: tip
Field fragments ship in `studiometa/foehn-acf`, the optional ACF package. See [ACF Blocks](/guide/acf-blocks#requirements).
:::
Field Fragments are reusable ACF field groups that can be shared across multiple blocks. By extending `FieldsBuilder`, you create self-contained field definitions that you add to any block's fields with acf-builder's `addFields()`.
## Why Use Field Fragments?
When building ACF blocks, you often repeat the same field patterns:
* Button/link with text, URL, target, and style
* Responsive images with mobile/desktop variants
* Spacing controls (margin, padding)
* Background settings (color, image, overlay)
Instead of duplicating these fields in every block, create a **Field Fragment** once and reuse it everywhere.
## Built-in Fragments
Føhn provides common fragments out of the box:
| Fragment | Description | Field names |
| ------------------------ | ------------------------------------ | ------------------------------------------------------ |
| `ButtonLinkBuilder` | Link with style and size options | `link`, `style`, `size` |
| `ResponsiveImageBuilder` | Desktop/mobile image variants | `desktop`, `mobile` |
| `SpacingBuilder` | Padding top/bottom controls | `top`, `bottom` |
| `BackgroundBuilder` | Color, image, and overlay background | `type`, `color`, `image`, `overlay`, `overlay_opacity` |
```php
use Studiometa\Foehn\Acf\Fragments\ButtonLinkBuilder;
use Studiometa\Foehn\Acf\Fragments\ResponsiveImageBuilder;
use Studiometa\Foehn\Acf\Fragments\SpacingBuilder;
use Studiometa\Foehn\Acf\Fragments\BackgroundBuilder;
```
## Creating Custom Fragments
A Field Fragment extends `FieldsBuilder` and configures its fields in the constructor:
```php
$label]);
$this
->addOembed('url', [
'label' => 'Video URL',
'instructions' => 'YouTube or Vimeo URL',
])
->addImage('poster', [
'label' => 'Poster Image',
'instructions' => 'Custom thumbnail (optional)',
'return_format' => 'id',
])
->addTrueFalse('autoplay', [
'label' => 'Autoplay',
'default_value' => false,
]);
}
}
```
## Using Fragments in Blocks
Use acf-builder's `addFields()` to add a fragment to your block's field configuration. `addFields()` copies the fragment's fields into the builder, with the field names that the fragment gives them.
The first constructor argument of a fragment (`'cta'` in `new ButtonLinkBuilder('cta')`) names the fragment's own builder. It does **not** prefix the field names: every `ButtonLinkBuilder` creates `link`, `style` and `size`. Wrap each fragment in `addGroup()` so that its fields have their own namespace:
```php
addWysiwyg('content', ['label' => 'Content'])
// Add the built-in fragments, each one in its own group
->addGroup('cta', ['label' => 'Call to Action'])
->addFields(new ButtonLinkBuilder())
->endGroup()
->addGroup('background', ['label' => 'Background'])
->addFields(new BackgroundBuilder())
->endGroup();
return $builder;
}
// ...
}
```
This produces:
* `content` (wysiwyg)
* `cta` (group)
* `link` (link)
* `style` (select)
* `size` (select)
* `background` (group)
* `type` (button\_group)
* `color` (color\_picker)
* `image` (image)
* `overlay` (true\_false)
* `overlay_opacity` (range)
The block values are nested in the same way: `$fields['cta']['link']`, `$fields['background']['type']`. The conditional logic inside a fragment keeps working in a group, because acf-builder resolves it against the field keys of the group.
Without the groups, two fragments that share a field name throw a `FieldNameCollisionException` when you add the second one. For example, two `ButtonLinkBuilder` fragments in the same builder both create `link`.
## Customizing Built-in Fragments
All built-in fragments accept constructor parameters for customization. The examples below continue a `$builder` chain like the one in `HeroBlock`.
### ButtonLinkBuilder
```php
use Studiometa\Foehn\Acf\Fragments\ButtonLinkBuilder;
// Default usage
->addGroup('button')
->addFields(new ButtonLinkBuilder())
->endGroup()
// Custom styles, no size field
->addGroup('cta', ['label' => 'Call to Action'])
->addFields(new ButtonLinkBuilder(
name: 'cta',
label: 'Call to Action',
styles: ['primary' => 'Primary', 'ghost' => 'Ghost'],
sizes: null, // Disable size field
required: true,
))
->endGroup()
```
### ResponsiveImageBuilder
```php
use Studiometa\Foehn\Acf\Fragments\ResponsiveImageBuilder;
// Default usage
->addGroup('image')
->addFields(new ResponsiveImageBuilder())
->endGroup()
// With custom instructions
->addGroup('hero_image', ['label' => 'Hero Image'])
->addFields(new ResponsiveImageBuilder(
name: 'hero_image',
label: 'Hero Image',
required: true,
desktopInstructions: 'Recommended: 2560×1440px',
mobileInstructions: 'Recommended: 750×1334px',
))
->endGroup()
```
### SpacingBuilder
```php
use Studiometa\Foehn\Acf\Fragments\SpacingBuilder;
// Default usage
->addGroup('spacing')
->addFields(new SpacingBuilder())
->endGroup()
// Custom sizes and labels
->addGroup('margin', ['label' => 'Margins'])
->addFields(new SpacingBuilder(
name: 'margin',
label: 'Margins',
sizes: ['0' => 'None', '1' => 'Small', '2' => 'Medium', '3' => 'Large'],
default: '1',
topLabel: 'Margin Top',
bottomLabel: 'Margin Bottom',
))
->endGroup()
```
### BackgroundBuilder
```php
use Studiometa\Foehn\Acf\Fragments\BackgroundBuilder;
// Default usage
->addGroup('background')
->addFields(new BackgroundBuilder())
->endGroup()
// Image-only background (no color option)
->addGroup('bg', ['label' => 'Background'])
->addFields(new BackgroundBuilder(
name: 'bg',
label: 'Background',
types: ['none' => 'None', 'image' => 'Image'],
default: 'none',
defaultOpacity: 70,
))
->endGroup()
```
## Organizing Fragments with Tabs
Fragments work well inside tabs for better editor UX:
```php
public static function fields(): FieldsBuilder
{
$builder = new FieldsBuilder('hero');
$builder
->addTab('Content')
->addText('title')
->addWysiwyg('content')
->addGroup('cta', ['label' => 'Call to Action'])
->addFields(new ButtonLinkBuilder())
->endGroup()
->addTab('Media')
->addGroup('hero_image', ['label' => 'Hero Image'])
->addFields(new ResponsiveImageBuilder(required: true))
->endGroup()
->addTab('Settings')
->addGroup('spacing', ['label' => 'Spacing'])
->addFields(new SpacingBuilder())
->endGroup()
->addGroup('background', ['label' => 'Background'])
->addFields(new BackgroundBuilder())
->endGroup();
return $builder;
}
```
`addTab()` returns the new tab field, not `$builder`. Keep the chain on a `$builder` variable and return the variable.
## File Structure
Organize fragments in a dedicated directory:
```
app/
├── Acf/
│ └── Fragments/
│ ├── BackgroundBuilder.php
│ ├── ButtonLinkBuilder.php
│ ├── ResponsiveImageBuilder.php
│ └── SpacingBuilder.php
│
├── Blocks/
│ ├── Hero/
│ │ └── HeroBlock.php
│ └── Features/
│ └── FeaturesBlock.php
```
## Best Practices
### 1. Use Constructor Parameters for Customization
Allow fragments to be configured when instantiated:
```php
final class ButtonLinkBuilder extends FieldsBuilder
{
public function __construct(
string $name = 'button',
string $label = 'Button',
array $styles = ['primary', 'secondary'],
bool $required = false,
) {
parent::__construct($name, ['label' => $label]);
$this
->addLink('link', [
'label' => 'Link',
'required' => $required,
])
->addSelect('style', [
'label' => 'Style',
'choices' => array_combine($styles, array_map('ucfirst', $styles)),
]);
}
}
```
### 2. Keep Fragments Focused
Each fragment should handle one concern. Prefer multiple small fragments over one large one:
```php
// ✅ Good: focused fragments
->addGroup('cta')
->addFields(new ButtonLinkBuilder())
->endGroup()
->addGroup('spacing')
->addFields(new SpacingBuilder())
->endGroup()
// ❌ Avoid: kitchen-sink fragment
->addFields(new ButtonWithSpacingAndBackgroundBuilder())
```
### 3. Document Field Names
Fragments do not prefix field names, so document the names that a fragment creates:
```php
/**
* Creates the following fields:
* - link (link)
* - style (select)
* - size (select) - optional
*/
final class ButtonLinkBuilder extends FieldsBuilder
```
### 4. Use Static Factory Methods for Presets
For common configurations, add static factory methods to your own fragment:
```php
final class ButtonLinkBuilder extends FieldsBuilder
{
// Constructor from practice 1...
public static function primary(string $name = 'cta'): self
{
return new self($name, 'Call to Action', ['primary', 'secondary']);
}
}
// Usage
->addGroup('cta')
->addFields(ButtonLinkBuilder::primary())
->endGroup()
```
## See Also
* [ACF Blocks](./acf-blocks) — Creating ACF blocks with `#[AsAcfBlock]`
* [acf-builder documentation](https://github.com/StoutLogic/acf-builder) — Full FieldsBuilder API
---
---
url: /foehn-framework/guide/acf-options-pages.md
---
# ACF Options Pages
Føhn provides `#[AsAcfOptionsPage]` for creating ACF options pages with type-safe fields using `stoutlogic/acf-builder`.
## Requirements
ACF is an optional package. Custom fields do not need it: [`#[AsPostMeta]`](/api/as-post-meta) registers a meta key with a REST schema, which is what puts a field in the block editor and makes it bindable. ACF is the better answer when the editing UI matters — repeaters, flexible content, conditional logic, media pickers.
```bash
composer require studiometa/foehn-acf
```
* [ACF Pro](https://www.advancedcustomfields.com/pro/) installed and active
* `studiometa/foehn-acf`, which brings `stoutlogic/acf-builder` with it. It requires the exact version of `studiometa/foehn` you have: upgrade the two packages together
Nothing registers when ACF is absent: each discovery guards on the function it needs. The classes keep their `Studiometa\Foehn\` namespaces, so a project upgrading from 0.4 changes one Composer requirement and no imports.
## Basic Options Page
```php
addText('site_name', ['label' => 'Site Name'])
->addTextarea('footer_text', ['label' => 'Footer Text'])
->addImage('logo', ['label' => 'Site Logo']);
}
}
```
## Retrieving Option Values
### Using AcfOptionsService
```php
with('site_name', $this->options->get('site_name', 'theme-settings'))
->with('footer_text', $this->options->get('footer_text', 'theme-settings'))
->with('logo', $this->options->get('logo', 'theme-settings'));
}
}
```
### Using ACF Functions Directly
```php
// Get a single field
$siteName = get_field('site_name', 'theme-settings');
// Get all fields
$settings = get_fields('theme-settings');
```
## Sub-Pages
Create child pages under a parent options page:
```php
addUrl('facebook', ['label' => 'Facebook URL'])
->addUrl('twitter', ['label' => 'Twitter URL'])
->addUrl('instagram', ['label' => 'Instagram URL'])
->addUrl('linkedin', ['label' => 'LinkedIn URL']);
}
}
```
## Full Configuration
```php
#[AsAcfOptionsPage(
pageTitle: 'Advanced Settings',
menuTitle: 'Advanced',
menuSlug: 'advanced-settings',
capability: 'manage_options',
position: 59,
iconUrl: 'dashicons-admin-settings',
redirect: false,
postId: 'advanced_options',
autoload: true,
updateButton: 'Save Settings',
updatedMessage: 'Settings saved successfully!',
)]
```
## Options Without Fields
You can create options pages without implementing `AcfOptionsPageInterface`. This is useful when fields are defined elsewhere or via the ACF UI:
```php
get('field_name', 'options-page-slug');
// Get all fields from an options page
$allFields = $options->all('options-page-slug');
// Check if a field has a value
if ($options->has('field_name', 'options-page-slug')) {
// Field has a non-empty value
}
// Get field object with metadata
$fieldObject = $options->getObject('field_name', 'options-page-slug');
```
## Using in Twig Templates
```twig
{# Get options in a context provider and pass to template #}
```
## Related
* [API: #\[AsAcfOptionsPage\]](/api/as-acf-options-page)
* [API: AcfOptionsPageInterface](/api/acf-options-page-interface)
* [Guide: ACF Blocks](/guide/acf-blocks)
* [Guide: Context Providers](/guide/context-providers)
---
---
url: /foehn-framework/api/acf-block-interface.md
---
# AcfBlockInterface
Interface for ACF (Advanced Custom Fields) blocks.
## Signature
```php
$block Block data from ACF
* @param array $fields Field values from get_fields()
* @return array|Arrayable Context for the template
*/
public function compose(array $block, array $fields): array|Arrayable;
/**
* Render the block.
*
* @param array $context Composed context
* @param bool $isPreview Whether rendering in editor preview
* @return string Rendered HTML
*/
public function render(array $context, bool $isPreview = false): string;
}
```
## Methods
### fields()
Define ACF fields using `stoutlogic/acf-builder`. This is a static method called during registration.
```php
public static function fields(): FieldsBuilder
{
return (new FieldsBuilder('hero'))
->addText('title', ['label' => 'Title'])
->addWysiwyg('content', ['label' => 'Content'])
->addImage('background', ['label' => 'Background']);
}
```
### compose()
Transform ACF field values into template context.
```php
public function compose(array $block, array $fields): array
{
return [
'title' => $fields['title'] ?? '',
'content' => $fields['content'] ?? '',
'background' => $fields['background'] ?? null,
'block_id' => $block['id'] ?? '',
'block_classes' => $block['className'] ?? '',
];
}
```
### render()
Render the block HTML. Receives the composed context and preview flag.
```php
public function render(array $context, bool $isPreview = false): string
{
// Handle empty state in preview
if ($isPreview && empty($context['title'])) {
return '
Add content
';
}
return $this->view->render('blocks/hero', $context);
}
```
## Usage
```php
addText('title')
->addWysiwyg('content')
->addImage('background');
}
public function compose(array $block, array $fields): array
{
return [
'title' => $fields['title'] ?? '',
'content' => $fields['content'] ?? '',
'background' => $fields['background'] ?? null,
];
}
public function render(array $context, bool $isPreview = false): string
{
return $this->view->render('blocks/hero', $context);
}
}
```
## Complex Fields Example
```php
public static function fields(): FieldsBuilder
{
return (new FieldsBuilder('features'))
->addTab('Content')
->addText('title')
->addTextarea('description')
->addRepeater('items', ['layout' => 'block'])
->addImage('icon')
->addText('title')
->addTextarea('text')
->endRepeater()
->addTab('Settings')
->addSelect('columns', [
'choices' => [
'2' => '2 Columns',
'3' => '3 Columns',
'4' => '4 Columns',
],
])
->addColorPicker('background_color');
}
```
## Validation
Use the `ValidatesFields` trait to add optional validation in your `compose()` method:
```php
use Studiometa\Foehn\Blocks\Concerns\ValidatesFields;
final readonly class HeroBlock implements AcfBlockInterface
{
use ValidatesFields;
public function compose(array $block, array $fields): array
{
// Simple: validate required and sanitize
$this->validateRequired($fields, ['title']);
return [
'title' => $this->sanitizeField($fields['title'], 'string'),
'count' => $this->sanitizeField($fields['count'] ?? 0, 'int'),
];
// Or use schema-based validation
return $this->validateFields($fields, [
'title' => ['type' => 'string', 'required' => true],
'count' => ['type' => 'int', 'default' => 0],
]);
}
}
```
See [Field Validation](/guide/acf-blocks#field-validation) for full documentation.
## Related
* [Guide: ACF Blocks](/guide/acf-blocks)
* [`#[AsAcfBlock]`](./as-acf-block)
---
---
url: /foehn-framework/api/acf-config.md
---
# AcfConfig
Configuration class for ACF (Advanced Custom Fields) integration.
## Signature
```php
addText('sku', ['label' => 'SKU'])
->addNumber('price', ['label' => 'Price'])
->addWysiwyg('description', ['label' => 'Description']);
}
```
## Usage
```php
'product'],
)]
final class ProductFields implements AcfFieldGroupInterface
{
public static function fields(): FieldsBuilder
{
return (new FieldsBuilder('product_fields'))
->addText('sku', ['label' => 'SKU'])
->addNumber('price', ['label' => 'Price'])
->addWysiwyg('description', ['label' => 'Description']);
}
}
```
## Complex Fields Example
```php
public static function fields(): FieldsBuilder
{
return (new FieldsBuilder('property_fields'))
->addTab('General')
->addText('external_id', ['label' => 'External ID'])
->addText('address', ['label' => 'Address'])
->addNumber('bedrooms', ['label' => 'Bedrooms'])
->addNumber('bathrooms', ['label' => 'Bathrooms'])
->addTab('Media')
->addGallery('photos', ['label' => 'Photos'])
->addOembed('video_tour', ['label' => 'Video Tour'])
->addTab('Features')
->addRepeater('features', ['layout' => 'table'])
->addText('name')
->addText('value')
->endRepeater()
->addTab('Pricing')
->addNumber('price', ['label' => 'Price'])
->addSelect('status', [
'label' => 'Status',
'choices' => [
'available' => 'Available',
'pending' => 'Pending',
'sold' => 'Sold',
],
]);
}
```
## Comparison with AcfBlockInterface
| Feature | `AcfFieldGroupInterface` | `AcfBlockInterface` |
| ------------------ | -------------------------- | -------------------------------- |
| Purpose | Post/page/taxonomy fields | Gutenberg block fields |
| Methods | `fields()` only | `fields()`, `compose()`, `render()` |
| Location | Set via attribute | Automatically set to block |
| Rendering | WordPress handles display | Class handles rendering |
## Accessing Field Values
Field values are accessed via standard ACF functions in your templates or code:
```php
// In PHP
$sku = get_field('sku');
$price = get_field('price');
// In Twig (with Timber)
{{ post.meta('sku') }}
{{ post.meta('price') }}
// Or using ACF functions
{{ function('get_field', 'sku') }}
```
## Related
* [`#[AsAcfFieldGroup]`](./as-acf-field-group)
* [`AcfBlockInterface`](./acf-block-interface)
* [`#[AsAcfBlock]`](./as-acf-block)
---
---
url: /foehn-framework/api/acf-options-page-interface.md
---
# AcfOptionsPageInterface
Interface for ACF options pages that define fields programmatically.
## Signature
```php
interface AcfOptionsPageInterface
{
public static function fields(): FieldsBuilder;
}
```
## Methods
### fields()
Define ACF fields for this options page using `stoutlogic/acf-builder`.
**Returns:** `FieldsBuilder` — The configured fields builder
## Usage
### Basic Implementation
```php
addText('site_name', [
'label' => 'Site Name',
'instructions' => 'Enter your site name',
])
->addTextarea('footer_text', [
'label' => 'Footer Text',
'rows' => 4,
]);
}
}
```
### With Tabs and Groups
```php
public static function fields(): FieldsBuilder
{
return (new FieldsBuilder('theme_settings'))
->addTab('general', ['label' => 'General'])
->addText('site_name')
->addImage('logo')
->addTab('social', ['label' => 'Social Media'])
->addUrl('facebook')
->addUrl('twitter')
->addUrl('instagram')
->addTab('footer', ['label' => 'Footer'])
->addWysiwyg('footer_content')
->addText('copyright');
}
```
### With Repeater Fields
```php
public static function fields(): FieldsBuilder
{
return (new FieldsBuilder('partners_settings'))
->addRepeater('partners', ['label' => 'Partners', 'layout' => 'block'])
->addText('name', ['label' => 'Partner Name'])
->addImage('logo', ['label' => 'Partner Logo'])
->addUrl('website', ['label' => 'Website URL'])
->endRepeater();
}
```
### With Conditional Logic
```php
public static function fields(): FieldsBuilder
{
return (new FieldsBuilder('display_settings'))
->addTrueFalse('show_banner', [
'label' => 'Show Banner',
'default_value' => false,
])
->addImage('banner_image', ['label' => 'Banner Image'])
->conditional('show_banner', '==', '1')
->addText('banner_text', ['label' => 'Banner Text'])
->conditional('show_banner', '==', '1');
}
```
## When to Implement
Implement this interface when you want to:
* Define fields programmatically with type safety
* Version control your field definitions
* Share field configurations across environments
## When Not to Implement
Skip implementing this interface when:
* Fields are defined via ACF UI
* Fields are imported from JSON
* You're using a third-party ACF field group
```php
#[AsAcfOptionsPage(pageTitle: 'External Settings')]
final class ExternalSettings
{
// No interface needed - fields defined elsewhere
}
```
## Related
* [Guide: ACF Options Pages](/guide/acf-options-pages)
* [`#[AsAcfOptionsPage]`](./as-acf-options-page)
* [`AcfBlockInterface`](./acf-block-interface)
---
---
url: /foehn-framework/guide/admin-cache-controls.md
---
# Admin Cache Controls
Føhn adds one admin page and one admin-bar menu, so the two questions that come up at the worst moment can be answered without a shell: **what state is this installation actually in**, and **clear this page now**.
Both are on by default. There is nothing to add to `foehn.config.php`: a project that had to switch on the page it would read the cache's state on has no way to find out that it needed to.
## The Føhn page
A top-level **Føhn** entry in the admin menu, visible to users with `manage_options`. It reports:
| Row | What it says |
| --------------------- | -------------------------------------------------------------------------------------------- |
| `WP_ENVIRONMENT_TYPE` | The environment as `Helpers\Env` resolves it, not as a file declares it. |
| `WP_DEBUG` | Whether debug mode is on. |
| Configured | The `enabled` flag from your [`PageCacheConfig`](/api/page-cache-config). |
| Effective | **Active**, **Disabled**, or **enabled but unavailable in this environment**. |
| Cache path | Where responses are stored. |
| TTL | The configured lifetime, or "purge-driven" when it is `0`. |
| Cached responses | How many stored response bodies exist right now. |
| Section responses | How many of those are [section](/guide/section-rendering) fragments rather than whole pages. |
| Total size | What the cache directory holds on disk, headers sidecars included. |
| Last full purge | When the cache was last emptied in full. Per-URL and per-section clears are not recorded. |
| Last real run | When the container's WP-Cron runner last succeeded, or **Never**. |
The two environment rows are labelled with the constant names rather than with prose on purpose. When somebody reports that "it thinks it is staging", you both have to be looking at the same name.
The **Effective** row is worth reading before anything else. `enabled: true` in an environment your config does not list writes nothing and serves nothing, and a page that reported only "enabled" would send you hunting a broken cache when what you have is a missing environment name.
**Last real run is Never until the container's cron runner is in place.** That is the honest answer for a site with no real cron, and it is the same answer a site whose runner has broken gives — which is the reason the row exists.
## The two buttons on the page
* **Clear the whole page cache** — every stored response, pages and fragments alike.
* **Clear the section cache only** — the variants keyed by `foehn_sections`, leaving whole pages and unrelated keyed variants where they are.
Both are plain `POST` forms. **This page is the no-JavaScript path**: it works with scripting off, which is what makes it the one to rely on when something else on the site is broken.
## The admin-bar menu
A **Føhn Cache** node for `manage_options`, with three items:
* **Clear whole cache**
* **Clear section cache**
* **Clear this page** — the current post, its cached 404, its keyed query variants and its section fragments.
The third item appears **only** when WordPress itself supplies the post: a singular front-end request, or a post edit screen. Anywhere else it is absent rather than disabled, because an item that appears everywhere and works somewhere is one nobody learns to trust. It is also absent for a post no visitor could have been served — a draft has no cached page to clear.
The node's own link goes to the Føhn page and nothing else. **The items do not mutate anything through their `href`**: each one submits a hidden, nonce-protected form rendered in the footer, through a few lines of inline script. A link that cleared a cache would be cleared by every prefetching browser, every link checker and every crawler holding a logged-in cookie, and no nonce fixes that — the browser is following the link on the user's behalf. With scripting off the items do nothing, which is the correct failure for a control that must not fire by accident; the page's buttons are still there.
## What the handlers require
Every mutation goes through one of three `admin_post_` handlers, and each of them refuses a request that fails any of the following:
1. **The request must be a `POST`.** `admin-post.php` fires `admin_post_{action}` for a `GET` too, so this is a real check and not decoration.
2. **The user must have `manage_options`.**
3. **The nonce must have been minted for that action.** Each action has its own nonce action string, so the token on "clear everything" cannot authorise "clear this post".
4. **A post id, when one is present, must resolve to a real, publicly viewable post.**
A refused request answers `403` and says nothing about which check failed. A successful one redirects — through `wp_safe_redirect()`, to a referrer WordPress itself validated or to the Føhn page — carrying only a fixed result code and an integer count.
**The browser posts an id and never a URL or a path.** The permalink is resolved server-side with `get_permalink()`, so there is no parameter for a caller to point somewhere else. That is by construction rather than by validation: a handler that accepted a filesystem path would be a remote deletion endpoint one bug away from working.
Deletion itself is [`PageCache\Invalidator`](/guide/page-cache#invalidation) and nothing else, so a button and an automatic purge always agree about which files a page owns. A clear takes the static page cache and only that — the discovery cache, transformed images, application transients and PHP's OPcache have their own lifecycles.
## They keep working with caching off
Every control stays available while `enabled` is `false`, and the page says so. A release that had the cache on leaves files behind, and the project switching it off is exactly the one who needs them gone.
## Reaching the same operations elsewhere
| Where | How |
| ------------- | ------------------------------------------------------------------------------------------ |
| WP-CLI | `wp foehn cache:clear [--url=]`, `wp foehn cache:status` |
| Your own code | `do_action('foehn/page_cache/purge_post', $postId)`, `do_action('foehn/page_cache/flush')` |
See [Page Cache](/guide/page-cache) for what is stored, how invalidation is triggered, and how to install the fast path.
---
---
url: /foehn-framework/guide/ai-agents.md
---
# AI Agents
Føhn ships [Agent Skills](https://agentskills.io/): reference files that teach a coding agent the framework's attributes, conventions and commands. Each package carries its own skill next to its code, so the skill is versioned with the release it describes.
| Skill | Package | Source |
| ------------------- | ------------------------------- | -------------------------------------------------------- |
| `foehn` | `studiometa/foehn` | `packages/foehn/skills/foehn/SKILL.md` |
| `foehn-acf` | `studiometa/foehn-acf` | `packages/acf/skills/foehn-acf/SKILL.md` |
| `foehn-vite-plugin` | `@studiometa/foehn-vite-plugin` | `packages/vite-plugin/skills/foehn-vite-plugin/SKILL.md` |
## Install
The [`skills` CLI](https://github.com/vercel-labs/skills) installs the skills from the GitHub repository for Claude Code, Cursor, Codex, Copilot and more than 40 other agents:
```bash
npx skills add studiometa/foehn-framework
```
The command asks which skills to install and for which agents. Pass the choices as options to skip the prompts:
```bash
npx skills add studiometa/foehn-framework -s foehn -s foehn-acf -a claude-code -y
```
| Option | Effect |
| ------------ | ------------------------------------------ |
| `-s ` | Install this skill. Repeat for each skill. |
| `-a ` | Install for this agent. |
| `-y` | Skip the prompts. |
## Pin to your release
Without a ref, the CLI installs the skills from `main`. `main` can describe attributes or options that your project's release does not have yet. Pin the skills to the version of `studiometa/foehn` in your `composer.lock`:
```bash
composer show studiometa/foehn | grep versions
npx skills add studiometa/foehn-framework#0.6.2
```
Tags have no `v` prefix: use `#0.6.2`, not `#v0.6.2`. A tree URL works too:
```bash
npx skills add https://github.com/studiometa/foehn-framework/tree/0.6.2
```
The CLI records the ref in `skills-lock.json`. `npx skills update` keeps that ref. To move to a new release, after `composer update studiometa/foehn`, run `add` again with the new tag:
```bash
npx skills add studiometa/foehn-framework#0.7.0
```
Commit `skills-lock.json` with the rest of the project, so every developer and agent reads the same skills.
## Without skills
Agents that read documentation directly can use the files the documentation build emits:
* [`llms.txt`](https://studiometa.github.io/foehn-framework/llms.txt) — an index of every page;
* [`llms-full.txt`](https://studiometa.github.io/foehn-framework/llms-full.txt) — every page in one file.
These files follow the current documentation, not a release.
---
---
url: /foehn-framework/api.md
---
# API Reference
This section documents all attributes, interfaces, and core classes in Føhn.
## Attributes
Attributes are PHP 8 annotations that enable auto-discovery and registration of WordPress components.
### Hooks
| Attribute | Description |
| ---------------------------------- | -------------------------------- |
| [`#[AsAction]`](./as-action) | Register a WordPress action hook |
| [`#[AsFilter]`](./as-filter) | Register a WordPress filter hook |
| [`#[AsShortcode]`](./as-shortcode) | Register a shortcode handler |
### Content Types
| Attribute | Description |
| --------------------------------------- | ------------------------------------------ |
| [`#[AsPostType]`](./as-post-type) | Register a custom post type |
| [`#[AsPostMeta]`](./as-post-meta) | Register a meta key, with a REST schema |
| [`#[AsTaxonomy]`](./as-taxonomy) | Register a custom taxonomy |
| [`#[AsMenu]`](./as-menu) | Register a navigation menu location |
| [`#[AsTimberModel]`](./as-timber-model) | Map Timber class without type registration |
### Media
| Attribute | Description |
| ----------------------------------- | ---------------------------- |
| [`#[AsImageSize]`](./as-image-size) | Register a custom image size |
### Views
| Attribute | Description |
| ----------------------------------------------------- | ------------------------------ |
| [`#[AsContextProvider]`](./as-context-provider) | Add data to specific templates |
| [`#[AsTemplateController]`](./as-template-controller) | Handle template rendering |
### Twig
| Attribute | Description |
| ------------------------------------------- | ------------------------- |
| [`#[AsTwigExtension]`](./as-twig-extension) | Register a Twig extension |
### Blocks
| Attribute | Description |
| -------------------------------------------- | --------------------------------- |
| [`#[AsBlock]`](./as-block) | Register a native Gutenberg block |
| [`#[AsAcfBlock]`](./as-acf-block) | Register an ACF block |
| [`#[AsAcfFieldGroup]`](./as-acf-field-group) | Register an ACF field group |
| [`#[AsBlockPattern]`](./as-block-pattern) | Register a block pattern |
| [`#[AsBlockCategory]`](./as-block-category) | Register a block category |
| [`#[AsBlockBinding]`](./as-block-binding) | Register a block bindings source |
### ACF
| Attribute | Description |
| ---------------------------------------------- | ---------------------------- |
| [`#[AsAcfOptionsPage]`](./as-acf-options-page) | Register an ACF options page |
### Settings
| Attribute | Description |
| ----------------------------------------- | ------------------------------------------- |
| [`#[AsSettingsPage]`](./as-settings-page) | An admin page on the WordPress Settings API |
### API & CLI
| Attribute | Description |
| --------------------------------------- | -------------------------------------- |
| [`#[AsRestRoute]`](./as-rest-route) | Register a REST API endpoint |
| [`#[AsRewriteRule]`](./as-rewrite-rule) | Register a rewrite rule, and answer it |
| [`#[AsCliCommand]`](./as-cli-command) | Register a WP-CLI command |
## Interfaces
Interfaces define contracts for classes used with specific attributes.
| Interface | Used with |
| ---------------------------------------------------------------------- | -------------------------------- |
| [`BlockInterface`](./block-interface) | `#[AsBlock]` |
| [`InteractiveBlockInterface`](./interactive-block-interface) | `#[AsBlock]` with interactivity |
| [`AcfBlockInterface`](./acf-block-interface) | `#[AsAcfBlock]` |
| [`AcfFieldGroupInterface`](./acf-field-group-interface) | `#[AsAcfFieldGroup]` |
| [`AcfOptionsPageInterface`](./acf-options-page-interface) | `#[AsAcfOptionsPage]` (optional) |
| [`ContextProviderInterface`](./context-provider-interface) | `#[AsContextProvider]` |
| [`TemplateControllerInterface`](./template-controller-interface) | `#[AsTemplateController]` |
| [`RewriteHandlerInterface`](./as-rewrite-rule#rewritehandlerinterface) | `#[AsRewriteRule]` (optional) |
| [`BlockPatternInterface`](./block-pattern-interface) | `#[AsBlockPattern]` (optional) |
## Configuration
| Config Class | Config File | Description |
| --------------------------------- | ----------------------- | ------------------------ |
| [`FoehnConfig`](./foehn-config) | `app/foehn.config.php` | Core bootstrap settings |
| [`TimberConfig`](./timber-config) | `app/timber.config.php` | Template directories |
| [`AcfConfig`](./acf-config) | `app/acf.config.php` | ACF field transformation |
| [`RestConfig`](./rest-config) | `app/rest.config.php` | REST API permissions |
## Discovery
| Class | Description |
| ------------------------------------------------ | -------------------------------- |
| [`DiscoveryRunner`](./discovery-runner) | Orchestrates discovery lifecycle |
| [`#[AsDiscovery]`](./as-discovery) | Declares a discovery's phase |
| [`ViewEngineInterface`](./view-engine-interface) | View rendering abstraction |
## Core
| Class | Description |
| --------------------------------------- | ------------------------ |
| [`Kernel`](./kernel) | Main bootstrap class |
| [`Helpers`](./helpers) | Global helper functions |
| [`CacheInterface`](./cache-interface) | Injectable cache service |
| [`WebpackManifest`](./webpack-manifest) | Asset manifest helper |
## DTOs & Traits
| Class / Trait | Description |
| ---------------------------------------- | ------------------------------------ |
| [`Arrayable`](./arrayable) | Interface for DTO → array conversion |
| [`HasToArray`](./has-to-array) | Reflection-based `toArray()` trait |
| [`LinkData`](./data-dtos#linkdata) | DTO for link/button fields |
| [`ImageData`](./data-dtos#imagedata) | DTO for image/attachment fields |
| [`SpacingData`](./data-dtos#spacingdata) | DTO for spacing fields |
## Query
| Class | Description |
| ------------------------------------------ | ----------------------------- |
| [`PostQueryBuilder`](./post-query-builder) | Fluent post query builder |
| [`QueriesPostType`](./queries-post-type) | Trait for model query methods |
---
---
url: /foehn-framework/api/arrayable.md
---
# Arrayable
Interface for objects that can be converted to an associative array. Used by DTOs returned from `compose()` methods.
## Signature
```php
*/
public function toArray(): array;
}
```
## Usage
Implement this interface on DTOs used as block or pattern context:
```php
use Studiometa\Foehn\Concerns\HasToArray;
use Studiometa\Foehn\Contracts\Arrayable;
final readonly class CardContext implements Arrayable
{
use HasToArray;
public function __construct(
public string $title,
public string $excerpt,
public ?ImageData $image = null,
) {}
}
```
The `HasToArray` trait provides a reflection-based `toArray()` that converts public properties to snake\_case keys and recursively flattens nested `Arrayable` objects.
## Related
* [Guide: Arrayable DTOs](/guide/arrayable-dtos)
* [HasToArray](./has-to-array)
---
---
url: /foehn-framework/guide/arrayable-dtos.md
---
# Arrayable DTOs
Føhn supports typed DTOs (Data Transfer Objects) as an alternative to plain arrays for block and pattern `compose()` methods. DTOs provide autocompletion, type safety, and clear contracts for your template context.
## Overview
Instead of returning a plain `array` from `compose()`, you can return an `Arrayable` object. Føhn automatically flattens it to a snake\_case array before reaching `render()` and Twig templates.
**Before (plain array):**
```php
public function compose(array $block, array $fields): array
{
return [
'title' => $fields['title'] ?? '',
'background_image' => ImageData::fromAttachmentId($fields['background'] ?? null),
'cta_link' => LinkData::fromAcf($fields['cta'] ?? null),
];
}
```
**After (typed DTO):**
```php
public function compose(array $block, array $fields): HeroContext
{
return new HeroContext(
title: $fields['title'] ?? '',
backgroundImage: ImageData::fromAttachmentId($fields['background'] ?? null),
ctaLink: LinkData::fromAcf($fields['cta'] ?? null),
);
}
```
Both approaches produce the same template context (`title`, `background_image`, `cta_link`).
## Creating a DTO
Implement `Arrayable` and use the `HasToArray` trait:
```php
toArray();
// [
// 'title' => 'Welcome',
// 'background_image' => ['id' => 1, 'src' => '/img.jpg', 'alt' => '', 'width' => null, 'height' => null],
// 'cta_link' => ['url' => '/about', 'title' => 'Learn more', 'target' => ''],
// 'height' => 'medium',
// ]
```
### Customizing Key Mapping
Override `propertyToKey()` to change the mapping strategy:
```php
final readonly class MyContext implements Arrayable
{
use HasToArray;
// Keep camelCase keys instead of snake_case
protected function propertyToKey(string $name): string
{
return $name;
}
}
```
## Using in Blocks
All `compose()` methods on `AcfBlockInterface`, `BlockInterface`, and `BlockPatternInterface` accept either `array` or `Arrayable` as return type.
### ACF Block
```php
use Studiometa\Foehn\Attributes\AsAcfBlock;
use Studiometa\Foehn\Contracts\AcfBlockInterface;
use Studiometa\Foehn\Contracts\ViewEngineInterface;
use StoutLogic\AcfBuilder\FieldsBuilder;
#[AsAcfBlock(name: 'hero', title: 'Hero Banner')]
final readonly class HeroBlock implements AcfBlockInterface
{
public function __construct(
private ViewEngineInterface $view,
) {}
public static function fields(): FieldsBuilder
{
return (new FieldsBuilder('hero'))
->addText('title')
->addImage('background')
->addLink('cta');
}
public function compose(array $block, array $fields): HeroContext
{
return new HeroContext(
title: $fields['title'] ?? '',
backgroundImage: ImageData::fromAttachmentId($fields['background'] ?? null),
ctaLink: LinkData::fromAcf($fields['cta'] ?? null),
);
}
public function render(array $context, bool $isPreview = false): string
{
return $this->view->render('blocks/hero', $context);
}
}
```
### Block Pattern
```php
use Studiometa\Foehn\Attributes\AsBlockPattern;
use Studiometa\Foehn\Contracts\BlockPatternInterface;
#[AsBlockPattern(name: 'theme/featured', title: 'Featured Section')]
final class FeaturedPattern implements BlockPatternInterface
{
public function context(): FeaturedContext
{
return new FeaturedContext(
posts: \Timber\Timber::get_posts(['posts_per_page' => 3]),
);
}
}
```
## Built-in DTOs
Føhn provides DTOs for common ACF field patterns:
### LinkData
Matches ACF link fields (return\_format: array).
```php
use Studiometa\Foehn\Data\LinkData;
// From ACF link field
$link = LinkData::fromAcf($fields['cta']);
// → LinkData { url: '...', title: '...', target: '' }
// Manual construction
$link = new LinkData(url: '/about', title: 'About Us', target: '_blank');
// Access properties
$link->url; // string
$link->title; // string
$link->target; // string
// Convert to array
$link->toArray();
// ['url' => '/about', 'title' => 'About Us', 'target' => '_blank']
```
Returns `null` if the ACF field is empty or null.
### ImageData
Matches ACF image fields (return\_format: id).
```php
use Studiometa\Foehn\Data\ImageData;
// From WordPress attachment ID
$image = ImageData::fromAttachmentId($fields['background'], 'large');
// → ImageData { id: 42, src: 'https://...', alt: '...', width: 1920, height: 1080 }
// Manual construction
$image = new ImageData(id: 42, src: '/img.jpg', alt: 'Photo', width: 800, height: 600);
// Access properties
$image->id; // int
$image->src; // string
$image->alt; // string
$image->width; // ?int
$image->height; // ?int
```
Returns `null` if the ID is invalid or the attachment doesn't exist.
### SpacingData
Matches fields produced by `SpacingBuilder`.
```php
use Studiometa\Foehn\Data\SpacingData;
// From the group that holds the fragment: addGroup('spacing')->addFields(new SpacingBuilder())
$spacing = SpacingData::fromAcf($fields['spacing'] ?? null);
// → SpacingData { top: 'large', bottom: 'medium' }
// Manual construction
$spacing = new SpacingData(top: 'large', bottom: 'small');
// Access properties
$spacing->top; // string (default: 'medium')
$spacing->bottom; // string (default: 'medium')
```
## In Twig Templates
DTO properties are available as snake\_case keys:
```twig
{% verbatim %}{# blocks/hero.twig #}
{% if background_image %}
{% endif %}
{{ title }}
{% if cta_link %}
{{ cta_link.title }}
{% endif %}
{% endverbatim %}
```
## Related
* [ACF Blocks](/guide/acf-blocks) — Using DTOs in ACF blocks
* [Block Patterns](/guide/block-patterns) — Using DTOs in block patterns
* [Field Fragments](/guide/field-fragments) — ACF field builder helpers
---
---
url: /foehn-framework/guide/assets.md
---
# Assets
Føhn provides two helpers for enqueuing scripts and styles, one per build tool.
| Build tool | Helper | Reads |
| ---------------------------------------------------------------------------- | ----------------- | --------------------------- |
| [Vite](https://vite.dev), through `@studiometa/foehn-vite-plugin` | `ViteManifest` | `dist/.vite/manifest.json` |
| [`@studiometa/webpack-config`](https://github.com/studiometa/webpack-config) | `WebpackManifest` | `dist/assets-manifest.json` |
They are not interchangeable: the two tools emit different formats. Reach for `ViteManifest` on a new project — it is what the starter and the demo use.
## Vite
```php
enqueue('theme/assets/css/app.css', handle: 'theme-styles')
->enqueue('theme/assets/js/app.js', handle: 'theme-app', inFooter: true);
}
}
```
Entry names are the paths given to the plugin's `input` in `vite.config.js`, because those are the keys Vite writes into the manifest. They are relative to the Vite project root, which is usually the package rather than the theme — so `theme/assets/js/app.js`, not `assets/js/app.js`.
### What it handles for you
**The dev server.** While `npm run dev` runs, the plugin writes a `hot` file into the build directory (`theme/dist/hot`), holding the server's URL. `ViteManifest` then loads the Vite client and the entries from that server instead of from the build, so hot module replacement works and a stale `dist/` cannot shadow your edits. Nothing in the theme has to branch on it. In a DDEV project, the dev server URL also shows the site: Vite answers its own routes and every file it holds, and proxies the other requests to `https://.ddev.site`.
**The CSS a script imported.** A Vite JavaScript chunk carries the stylesheets it imported in a `css` array, separate from its own `file`. Enqueue the script and miss that array and the page loads with no styles and no error anywhere — so `enqueue()` always registers both.
**The module type.** Vite emits ES modules, and a classic `` |
| `wp_kses_post()` | HTML content (allows safe tags) | `` |
| `wp_kses()` | HTML with custom allowed tags | `echo wp_kses($html, $allowed_tags);` |
### When to Use Each Function
#### `esc_html()` — Plain Text
Use for any text that should not contain HTML:
```php
// ✅ Good: Escaped output
echo '
' . esc_html($title) . '
';
// ❌ Bad: Unescaped user input
echo '
' . $title . '
';
```
#### `esc_attr()` — HTML Attributes
Use for values inside HTML attributes:
```php
// ✅ Good: Escaped attribute
echo '';
echo '
';
echo '
';
// ❌ Bad: Unescaped attribute (XSS via " onclick="alert(1))
echo '';
```
#### `esc_url()` — URLs
Use for any URL output:
```php
// ✅ Good: Escaped URL
echo 'Click';
echo '';
// ❌ Bad: Allows javascript: protocol XSS
echo 'Click';
```
#### `wp_kses_post()` — Rich HTML Content
Use when you need to allow safe HTML (like post content):
```php
// ✅ Good: Allows safe HTML, strips dangerous tags
echo '