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: 'layout',
    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>

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.

Enabled by Default ​

Field transformation is enabled by default. To disable it, create an ACF config file:

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\PostQuery (array of Images)
fileTimber\Attachment
post_objectTimber\Post (or PostQuery if multiple)
relationshipTimber\PostQuery
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\PostQuery
    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: 'common',
    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: 'layout',
    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'common'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.