Skip to content

Post Types

Foehn uses #[AsPostType] to register custom post types with Timber integration.

Basic Post Type

php
<?php
// app/Models/Product.php

namespace App\Models;

use Studiometa\Foehn\Attributes\AsPostType;
use Timber\Post;

#[AsPostType(
    name: 'product',
    singular: 'Product',
    plural: 'Products',
)]
final class Product extends Post
{
}

This registers a post type with sensible defaults and automatically maps it in Timber's classmap.

Full Configuration

php
<?php

namespace App\Models;

use Studiometa\Foehn\Attributes\AsPostType;
use Timber\Post;

#[AsPostType(
    name: 'product',
    singular: 'Product',
    plural: 'Products',
    public: true,
    hasArchive: true,
    showInRest: true,
    menuIcon: 'dashicons-cart',
    supports: ['title', 'editor', 'thumbnail', 'excerpt', 'custom-fields'],
    taxonomies: ['product_category', 'product_tag'],
    rewriteSlug: 'shop',
)]
final class Product extends Post
{
}

Custom Methods

Add business logic directly to your post type class:

php
<?php

namespace App\Models;

use Studiometa\Foehn\Attributes\AsPostType;
use Timber\Post;

#[AsPostType(
    name: 'product',
    singular: 'Product',
    plural: 'Products',
    hasArchive: true,
    menuIcon: 'dashicons-cart',
)]
final class Product extends Post
{
    /**
     * Get the product price.
     */
    public function price(): ?float
    {
        $price = $this->meta('price');
        return $price ? (float) $price : null;
    }

    /**
     * Get the formatted price.
     */
    public function formattedPrice(): string
    {
        $price = $this->price();
        return $price ? sprintf('$%.2f', $price) : 'Price on request';
    }

    /**
     * Check if the product is on sale.
     */
    public function isOnSale(): bool
    {
        return (bool) $this->meta('on_sale');
    }

    /**
     * Get the sale price if on sale.
     */
    public function salePrice(): ?float
    {
        if (!$this->isOnSale()) {
            return null;
        }

        $salePrice = $this->meta('sale_price');
        return $salePrice ? (float) $salePrice : null;
    }

    /**
     * Get related products.
     *
     * @return Product[]
     */
    public function relatedProducts(int $limit = 4): array
    {
        $categories = $this->terms('product_category');
        if (empty($categories)) {
            return [];
        }

        return \Timber\Timber::get_posts([
            'post_type' => 'product',
            'posts_per_page' => $limit,
            'post__not_in' => [$this->ID],
            'tax_query' => [
                [
                    'taxonomy' => 'product_category',
                    'terms' => wp_list_pluck($categories, 'term_id'),
                ],
            ],
        ]);
    }
}

Using in Templates

Your custom methods are available in Twig templates:

twig
{# views/single-product.twig #}
{% extends 'base.twig' %}

{% block content %}
<article class="product">
    <h1>{{ post.title }}</h1>

    {% if post.thumbnail %}
        <img src="{{ post.thumbnail.src('large') }}" alt="{{ post.title }}">
    {% endif %}

    <div class="product-price">
        {% if post.isOnSale %}
            <span class="original-price">{{ post.formattedPrice }}</span>
            <span class="sale-price">${{ post.salePrice|number_format(2) }}</span>
        {% else %}
            <span class="price">{{ post.formattedPrice }}</span>
        {% endif %}
    </div>

    <div class="product-content">
        {{ post.content }}
    </div>

    {% set related = post.relatedProducts(4) %}
    {% if related %}
        <section class="related-products">
            <h2>Related Products</h2>
            <div class="grid">
                {% for product in related %}
                    {% include 'partials/product-card.twig' with { product: product } %}
                {% endfor %}
            </div>
        </section>
    {% endif %}
</article>
{% endblock %}

Advanced Configuration

For complex post types, implement ConfiguresPostType interface:

php
<?php

namespace App\Models;

use Studiometa\Foehn\Attributes\AsPostType;
use Studiometa\Foehn\Contracts\ConfiguresPostType;
use Timber\Post;

#[AsPostType(name: 'event', singular: 'Event', plural: 'Events')]
final class Event extends Post implements ConfiguresPostType
{
    /**
     * Customize the post type arguments.
     */
    public static function postTypeArgs(array $args): array
    {
        // Add custom capabilities
        $args['capability_type'] = 'event';
        $args['map_meta_cap'] = true;

        // Customize labels
        $args['labels']['menu_name'] = 'Calendar';
        $args['labels']['all_items'] = 'All Events';

        return $args;
    }
}

Multiple Post Types

Each post type is a separate class:

app/Models/
├── Product.php
├── Event.php
├── Team.php
├── Testimonial.php
└── Portfolio.php

Attribute Parameters

ParameterTypeDefaultDescription
namestringrequiredPost type slug
singular?stringnullSingular label
plural?stringnullPlural label
publicbooltruePublic visibility
hasArchiveboolfalseEnable archive pages
showInRestbooltrueREST API & Gutenberg support
menuIcon?stringnullDashicon or URL
supportsstring[]['title', 'editor', 'thumbnail']Supported features
taxonomiesstring[][]Associated taxonomies
rewriteSlug?stringnullCustom URL slug

See Also

Released under the MIT License.