Skip to content

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] 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 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
<?php
// app/Blocks/Hero/HeroBlock.php

namespace App\Blocks\Hero;

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',
    category: 'design',
    icon: 'cover-image',
)]
final readonly class HeroBlock implements AcfBlockInterface
{
    public function __construct(
        private ViewEngineInterface $view,
    ) {}

    public static function fields(): FieldsBuilder
    {
        return (new FieldsBuilder('hero'))
            ->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 #}
<section class="hero" id="{{ block_id }}">
    {% if background %}
        <img
            class="hero__background"
            src="{{ background.src('full') }}"
            alt="{{ background.alt }}"
        >
    {% endif %}

    <div class="hero__content">
        {% if title %}
            <h1 class="hero__title">{{ title }}</h1>
        {% endif %}

        {% if content %}
            <div class="hero__text">{{ content }}</div>
        {% endif %}
    </div>
</section>

Field Values ​

$fields holds what 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
<?php
// app/acf.config.php

use Studiometa\Foehn\Config\AcfConfig;

return new AcfConfig(
    transformFields: false,
);

Transformed Field Types ​

ACF Field TypeTimber Type
imageTimber\Image
galleryTimber\PostArrayObject of images
fileTimber\Attachment
post_objectTimber\Post (or PostArrayObject if multiple)
relationshipTimber\PostArrayObject
taxonomyTimber\Term (or array of Terms)
userTimber\User (or array of Users)
date_pickerDateTimeImmutable
date_time_pickerDateTimeImmutable

Nested Fields Support ​

Transformation works recursively for nested field types:

  • Repeater: Each row's sub-fields are transformed
  • Flexible Content: Each layout's sub-fields are transformed
  • Group: All sub-fields are transformed

Example: Before and After ​

Without transformation (manual conversion required):

php
public function compose(array $block, array $fields): array
{
    $context = $fields;

    // Manual transformation for every image field
    if (!empty($fields['image'])) {
        $context['image'] = Timber::get_image($fields['image']);
    }

    // Manual transformation for relationships
    if (!empty($fields['related_posts'])) {
        $context['related_posts'] = Timber::get_posts($fields['related_posts']);
    }

    return $context;
}

With transformation (automatic):

php
public function compose(array $block, array $fields): array
{
    // $fields['image'] is already a Timber\Image
    // $fields['related_posts'] is already a Timber\PostArrayObject
    return $fields;
}

In Twig Templates ​

With automatic transformation, you can use Timber's full API directly:

twig
{# Image fields #}
<img
    src="{{ image.src('large') }}"
    alt="{{ image.alt }}"
    srcset="{{ image.srcset }}"
    width="{{ image.width }}"
    height="{{ image.height }}"
>

{# Gallery fields #}
{% for item in gallery %}
    <img src="{{ item.src('thumbnail') }}" alt="{{ item.alt }}">
{% endfor %}

{# Relationship fields #}
{% for post in related_posts %}
    <a href="{{ post.link }}">{{ post.title }}</a>
{% endfor %}

{# Date fields #}
<time datetime="{{ date|date('Y-m-d') }}">
    {{ date|date('F j, Y') }}
</time>

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

namespace App\Blocks\Features;

use Studiometa\Foehn\Attributes\AsAcfBlock;
use Studiometa\Foehn\Contracts\AcfBlockInterface;
use Studiometa\Foehn\Contracts\ViewEngineInterface;
use StoutLogic\AcfBuilder\FieldsBuilder;

#[AsAcfBlock(
    name: 'features',
    title: 'Features Grid',
    category: 'design',
    icon: 'grid-view',
)]
final readonly class FeaturesBlock implements AcfBlockInterface
{
    public function __construct(
        private ViewEngineInterface $view,
    ) {}

    public static function fields(): FieldsBuilder
    {
        $builder = new FieldsBuilder('features');

        $builder
            ->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
<?php

namespace App\Blocks\Hero;

use Studiometa\Foehn\Attributes\AsAcfBlock;
use Studiometa\Foehn\Blocks\Concerns\ValidatesFields;
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
{
    use ValidatesFields;

    public function __construct(
        private ViewEngineInterface $view,
    ) {}

    public static function fields(): FieldsBuilder
    {
        return (new FieldsBuilder('hero'))
            ->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 ​

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

TypeDescription
stringTrimmed string
intInteger (coerced from numeric strings)
floatFloat (coerced from numeric values)
boolBoolean (handles 'true', 'yes', '1', 'on')
arrayArray
htmlHTML content (sanitized via wp_kses_post)
emailEmail address (sanitized)
urlURL (sanitized via esc_url_raw)

Advanced Validation ​

For more advanced validation needs, consider using:

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 '<div class="acf-placeholder">Please add content</div>';
    }

    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 ​

ParameterTypeDefaultDescription
namestringrequiredBlock name (without acf/ prefix)
titlestringrequiredDisplay title
categorystring'widgets'Block category
icon?stringnullDashicon or SVG
description?stringnullBlock description
keywordsstring[][]Search keywords
modestring'preview''preview', 'edit', or 'auto'
supportsarray[]Block supports
template?stringnullCustom template path
postTypesstring[][]Allowed post types
parent?stringnullParent block name

See Also ​

Released under the MIT License.