Skip to content

ACF Blocks

Foehn provides #[AsAcfBlock] for creating ACF blocks with type-safe fields using stoutlogic/acf-builder.

Requirements

  • ACF Pro installed and active
  • stoutlogic/acf-builder package
bash
composer require stoutlogic/acf-builder

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
{# views/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, Foehn 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:

php
Kernel::boot(__DIR__, [
    'acf_transform_fields' => 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

Foehn 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

views/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.