Skip to content

Theme Conventions

This guide establishes the recommended structure and naming conventions for Føhn-based WordPress themes.

Directory Structure

theme/
├── app/                          # PHP application code
│   ├── Blocks/                   # ACF and native blocks
│   │   ├── Hero/
│   │   │   └── HeroBlock.php
│   │   └── Features/
│   │       └── FeaturesBlock.php
│   ├── Hooks/                    # WordPress action/filter handlers
│   │   ├── ThemeHooks.php
│   │   ├── AssetHooks.php
│   │   └── AdminHooks.php
│   ├── Models/                   # Post types and taxonomies
│   │   ├── Product.php
│   │   ├── Event.php
│   │   └── ProductCategory.php
│   ├── Patterns/                 # Block patterns
│   │   └── HeroPattern.php
│   ├── Rest/                     # REST API endpoints
│   │   └── ProductsEndpoint.php
│   ├── Services/                 # Business logic services
│   │   ├── CartService.php
│   │   └── NewsletterService.php
│   ├── Shortcodes/               # Shortcode handlers
│   │   └── ButtonShortcode.php
│   ├── Console/                  # CLI commands
│   │   └── ImportProductsCommand.php
│   ├── ContextProviders/         # Context providers
│   │   ├── GlobalContextProvider.php
│   │   └── NavigationContextProvider.php
│   └── Controllers/              # Template controllers
│       ├── HomeController.php
│       └── SingleController.php
├── views/                        # Twig templates
│   ├── base.twig                 # Base layout
│   ├── blocks/                   # Block templates
│   │   ├── hero.twig
│   │   └── features.twig
│   ├── components/               # Reusable components
│   │   ├── button.twig
│   │   ├── card.twig
│   │   └── pagination.twig
│   ├── pages/                    # Page-specific templates
│   │   ├── home.twig
│   │   └── contact.twig
│   └── partials/                 # Template partials
│       ├── header.twig
│       ├── footer.twig
│       └── sidebar.twig
├── assets/                       # Source assets
│   ├── scripts/
│   └── styles/
├── dist/                         # Compiled assets
├── functions.php                 # Kernel bootstrap
└── style.css                     # Theme metadata

PHP Naming Conventions

Post Types

ConventionExample
Locationapp/Models/
Class nameSingular PascalCase
File name{ClassName}.php
php
// app/Models/Product.php
#[AsPostType(name: 'product', singular: 'Product', plural: 'Products')]
final class Product extends Post {}

// app/Models/TeamMember.php
#[AsPostType(name: 'team_member', singular: 'Team Member', plural: 'Team Members')]
final class TeamMember extends Post {}

Taxonomies

ConventionExample
Locationapp/Models/ (alongside post types)
Class nameSingular PascalCase
File name{ClassName}.php
php
// app/Models/ProductCategory.php
#[AsTaxonomy(name: 'product_category', postTypes: ['product'])]
final class ProductCategory {}

// app/Models/EventType.php
#[AsTaxonomy(name: 'event_type', postTypes: ['event'])]
final class EventType {}

ACF Blocks

ConventionExample
Locationapp/Blocks/{BlockName}/
Class name{BlockName}Block
File name{BlockName}Block.php
php
// app/Blocks/Hero/HeroBlock.php
#[AsAcfBlock(name: 'hero', title: 'Hero Banner')]
final readonly class HeroBlock implements AcfBlockInterface {}

// app/Blocks/FeatureGrid/FeatureGridBlock.php
#[AsAcfBlock(name: 'feature-grid', title: 'Feature Grid')]
final readonly class FeatureGridBlock implements AcfBlockInterface {}

Each block has its own directory to keep related files together (PHP, assets, tests).

Native Blocks

ConventionExample
Locationapp/Blocks/{BlockName}/
Class name{BlockName}Block
File name{BlockName}Block.php
php
// app/Blocks/Counter/CounterBlock.php
#[AsBlock(name: 'theme/counter', title: 'Counter')]
final readonly class CounterBlock implements InteractiveBlockInterface {}

Hooks

ConventionExample
Locationapp/Hooks/
Class name{Domain}Hooks
File name{Domain}Hooks.php
php
// app/Hooks/ThemeHooks.php
final class ThemeHooks {
    #[AsAction('after_setup_theme')]
    public function setup(): void {}
}

// app/Hooks/AssetHooks.php
final class AssetHooks {
    #[AsAction('wp_enqueue_scripts')]
    public function enqueueAssets(): void {}
}

// app/Hooks/AdminHooks.php
final class AdminHooks {
    #[AsAction('admin_init')]
    public function initAdmin(): void {}
}

Context Providers

ConventionExample
Locationapp/ContextProviders/
Class name{Name}ContextProvider
File name{Name}ContextProvider.php
php
// app/ContextProviders/GlobalContextProvider.php
#[AsContextProvider('*')]
final class GlobalContextProvider implements ContextProviderInterface {}

// app/ContextProviders/NavigationContextProvider.php
#[AsContextProvider('*')]
final class NavigationContextProvider implements ContextProviderInterface {}

// app/ContextProviders/ProductContextProvider.php
#[AsContextProvider('single-product')]
final class ProductContextProvider implements ContextProviderInterface {}

Template Controllers

ConventionExample
Locationapp/Controllers/
Class name{Template}Controller
File name{Template}Controller.php
php
// app/Controllers/HomeController.php
#[AsTemplateController('front-page')]
final class HomeController implements TemplateControllerInterface {}

// app/Controllers/SingleProductController.php
#[AsTemplateController('single-product')]
final class SingleProductController implements TemplateControllerInterface {}

// app/Controllers/ArchiveController.php
#[AsTemplateController('archive')]
final class ArchiveController implements TemplateControllerInterface {}

REST Endpoints

ConventionExample
Locationapp/Rest/
Class name{Resource}Endpoint
File name{Resource}Endpoint.php
php
// app/Rest/ProductsEndpoint.php
final class ProductsEndpoint {
    #[AsRestRoute(namespace: 'theme/v1', route: '/products')]
    public function list(): WP_REST_Response {}
}

// app/Rest/NewsletterEndpoint.php
final class NewsletterEndpoint {
    #[AsRestRoute(namespace: 'theme/v1', route: '/newsletter', methods: ['POST'])]
    public function subscribe(WP_REST_Request $request): WP_REST_Response {}
}

Shortcodes

ConventionExample
Locationapp/Shortcodes/
Class name{Name}Shortcode
File name{Name}Shortcode.php
php
// app/Shortcodes/ButtonShortcode.php
#[AsShortcode('button')]
final class ButtonShortcode {
    public function render(array $atts, ?string $content): string {}
}

CLI Commands

ConventionExample
Locationapp/Console/
Class name{Name}Command
File name{Name}Command.php
php
// app/Console/ImportProductsCommand.php
#[AsCliCommand(name: 'import:products', description: 'Import products from CSV')]
final class ImportProductsCommand {
    public function __invoke(array $args, array $assocArgs): void {}
}

// app/Console/CacheCommand.php
#[AsCliCommand(name: 'cache', description: 'Manage application cache')]
final class CacheCommand {
    public function clear(): void {}
    public function warm(): void {}
}

Block Patterns

ConventionExample
Locationapp/Patterns/
Class name{Name}Pattern
File name{Name}Pattern.php
php
// app/Patterns/HeroPattern.php
#[AsBlockPattern(name: 'theme/hero', title: 'Hero Section')]
final readonly class HeroPattern implements BlockPatternInterface {}

Services

ConventionExample
Locationapp/Services/
Class name{Name}Service
File name{Name}Service.php
php
// app/Services/CartService.php
final readonly class CartService {
    public function getItemCount(): int {}
}

// app/Services/NewsletterService.php
final readonly class NewsletterService {
    public function subscribe(string $email): bool {}
}

Twig Template Conventions

Template Locations

Template TypeLocationExample
Base layoutsviews/views/base.twig
WordPress templatesviews/views/single.twig, views/archive.twig
Block templatesviews/blocks/views/blocks/hero.twig
Page templatesviews/pages/views/pages/home.twig
Partialsviews/partials/views/partials/header.twig
Componentsviews/components/views/components/button.twig
Pattern templatesviews/patterns/views/patterns/hero.twig

Template Naming

WordPress HierarchyTwig Template
index.phpviews/index.twig
front-page.phpviews/front-page.twig or views/pages/home.twig
single.phpviews/single.twig
single-{post_type}.phpviews/single-{post_type}.twig
archive.phpviews/archive.twig
archive-{post_type}.phpviews/archive-{post_type}.twig
page.phpviews/page.twig
page-{slug}.phpviews/page-{slug}.twig
category.phpviews/category.twig
taxonomy-{taxonomy}.phpviews/taxonomy-{taxonomy}.twig
search.phpviews/search.twig
404.phpviews/404.twig

Block Template Naming

Block templates should match the block name (without prefix):

php
// Block: #[AsAcfBlock(name: 'hero')]
// Template: views/blocks/hero.twig

// Block: #[AsAcfBlock(name: 'feature-grid')]
// Template: views/blocks/feature-grid.twig

// Block: #[AsBlock(name: 'theme/counter')]
// Template: views/blocks/counter.twig

Component Template Conventions

Components should be self-contained and reusable:

twig
{# views/components/button.twig #}
{% set classes = html_classes('btn', {
    'btn--primary': variant == 'primary',
    'btn--secondary': variant == 'secondary',
    'btn--large': size == 'large',
}) %}

<a href="{{ url }}" class="{{ classes }}">
    {{ label }}
</a>

Usage:

twig
{% include 'components/button.twig' with {
    label: 'Learn More',
    url: '/about',
    variant: 'primary',
} %}

Partial Template Conventions

Partials are template fragments that are included in layouts:

twig
{# views/partials/header.twig #}
<header class="site-header">
    <div class="site-header__logo">
        <a href="{{ site.url }}">{{ site.name }}</a>
    </div>
    <nav class="site-header__nav">
        {% include 'partials/navigation.twig' with { menu: menus.primary } %}
    </nav>
</header>

Namespace Conventions

Use a consistent namespace structure:

php
// Root namespace (defined in composer.json)
"autoload": {
    "psr-4": {
        "App\\": "app/"
    }
}
DirectoryNamespace
app/Blocks/App\Blocks
app/Console/App\Console
app/ContextProviders/App\ContextProviders
app/Controllers/App\Controllers
app/Hooks/App\Hooks
app/Models/App\Models
app/Patterns/App\Patterns
app/Rest/App\Rest
app/Services/App\Services
app/Shortcodes/App\Shortcodes

Migration from wp-toolkit

If migrating from studiometa/wp-toolkit, the directory structure changes significantly. See the Migration Guide for details.

Key Changes

wp-toolkitFøhn
app/PostTypes/ProductPostType.phpapp/Models/Product.php
app/Taxonomies/CategoryTaxonomy.phpapp/Models/Category.php
app/Blocks/HeroBlock.phpapp/Blocks/Hero/HeroBlock.php
Manual Manager registrationAutomatic discovery

File Relocation Checklist

  1. Post types: Move from app/PostTypes/ to app/Models/, rename from {Name}PostType.php to {Name}.php
  2. Taxonomies: Move from app/Taxonomies/ to app/Models/, rename from {Name}Taxonomy.php to {Name}.php
  3. Blocks: Move from app/Blocks/{Name}Block.php to app/Blocks/{Name}/{Name}Block.php
  4. Hooks: Create app/Hooks/ directory and extract hooks from functions.php
  5. Context Providers: Create app/ContextProviders/ directory
  6. Controllers: Create app/Controllers/ directory

Best Practices

Class Design

  • Use final for classes not designed for inheritance
  • Use readonly for immutable classes (blocks, services)
  • Use constructor property promotion
  • Implement the appropriate interface
php
// Good
final readonly class HeroBlock implements AcfBlockInterface {}

// Avoid
class HeroBlock {}

Single Responsibility

Each class should have one responsibility:

php
// Good: Separate classes for different concerns
final class ThemeHooks {}      // Theme setup
final class AssetHooks {}      // Asset enqueuing
final class AdminHooks {}      // Admin customizations

// Avoid: One class handling everything
final class Hooks {}           // Too broad

Dependency Injection

Inject dependencies through constructors:

php
final readonly class ProductController implements TemplateControllerInterface
{
    public function __construct(
        private ViewEngineInterface $view,
        private CartService $cart,
    ) {}
}

File Organization

  • One class per file
  • File name matches class name
  • Group related classes in directories

Enforcing Conventions with Mago

Mago is a fast PHP toolchain that includes an architectural guard feature. You can use it to automatically enforce theme conventions.

Installation

bash
composer require --dev carthage-software/mago

Quick Setup

Føhn includes a ready-to-use Mago configuration. Copy it to your theme:

bash
cp vendor/studiometa/foehn/resources/mago-theme.toml mago.toml

Then run:

bash
mago guard  # Check conventions

Manual Configuration

If you prefer to configure Mago manually, add the following rules to your theme's mago.toml:

Click to expand full configuration
toml
php-version = "8.4"

[source]
paths = ["app"]
includes = ["vendor"]
excludes = ["cache/**", "var/**", "node_modules/**"]

# =============================================================================
# Structural Guard Rules
# =============================================================================
# These rules enforce naming conventions and class structure for Føhn themes.

# -----------------------------------------------------------------------------
# Blocks: Must be final readonly, named *Block, implement interface
# -----------------------------------------------------------------------------
[[guard.structural.rules]]
on               = "App\\Blocks\\**"
target           = "class"
must-be-named    = "*Block"
must-be-final    = true
must-be-readonly = true
reason           = "Block classes must be final readonly and named *Block."

[[guard.structural.rules]]
on               = "App\\Blocks\\**"
target           = "class"
must-implement   = [
    ["Studiometa\\Foehn\\Contracts\\AcfBlockInterface"],
    ["Studiometa\\Foehn\\Contracts\\BlockInterface"],
    ["Studiometa\\Foehn\\Contracts\\InteractiveBlockInterface"],
]
reason           = "Block classes must implement AcfBlockInterface, BlockInterface, or InteractiveBlockInterface."

# -----------------------------------------------------------------------------
# Hooks: Must be final and named *Hooks
# -----------------------------------------------------------------------------
[[guard.structural.rules]]
on            = "App\\Hooks\\**"
target        = "class"
must-be-named = "*Hooks"
must-be-final = true
reason        = "Hook classes must be final and named *Hooks."

# -----------------------------------------------------------------------------
# Models (Post Types): Must be final and extend Timber\Post or Timber\Term
# -----------------------------------------------------------------------------
[[guard.structural.rules]]
on            = "App\\Models\\**"
target        = "class"
must-be-final = true
must-extend   = [
    ["Timber\\Post"],
    ["Timber\\Term"],
]
not-on        = "App\\Models\\**Interface"
reason        = "Model classes must be final and extend Timber\\Post or Timber\\Term."

# -----------------------------------------------------------------------------
# Patterns: Must be final readonly, named *Pattern, implement interface
# -----------------------------------------------------------------------------
[[guard.structural.rules]]
on               = "App\\Patterns\\**"
target           = "class"
must-be-named    = "*Pattern"
must-be-final    = true
must-be-readonly = true
reason           = "Pattern classes must be final readonly and named *Pattern."

[[guard.structural.rules]]
on             = "App\\Patterns\\**"
target         = "class"
must-implement = "Studiometa\\Foehn\\Contracts\\BlockPatternInterface"
reason         = "Pattern classes must implement BlockPatternInterface."

# -----------------------------------------------------------------------------
# REST Endpoints: Must be final and named *Endpoint
# -----------------------------------------------------------------------------
[[guard.structural.rules]]
on            = "App\\Rest\\**"
target        = "class"
must-be-named = "*Endpoint"
must-be-final = true
reason        = "REST endpoint classes must be final and named *Endpoint."

# -----------------------------------------------------------------------------
# Services: Must be final readonly and named *Service
# -----------------------------------------------------------------------------
[[guard.structural.rules]]
on               = "App\\Services\\**"
target           = "class"
must-be-named    = "*Service"
must-be-final    = true
must-be-readonly = true
reason           = "Service classes must be final readonly and named *Service."

# -----------------------------------------------------------------------------
# Shortcodes: Must be final and named *Shortcode
# -----------------------------------------------------------------------------
[[guard.structural.rules]]
on            = "App\\Shortcodes\\**"
target        = "class"
must-be-named = "*Shortcode"
must-be-final = true
reason        = "Shortcode classes must be final and named *Shortcode."

# -----------------------------------------------------------------------------
# Context Providers: Must be final, named *ContextProvider, implement interface
# -----------------------------------------------------------------------------
[[guard.structural.rules]]
on            = "App\\ContextProviders\\**"
target        = "class"
must-be-named = "*ContextProvider"
must-be-final = true
reason        = "Context provider classes must be final and named *ContextProvider."

[[guard.structural.rules]]
on             = "App\\ContextProviders\\**"
target         = "class"
must-implement = "Studiometa\\Foehn\\Contracts\\ContextProviderInterface"
reason         = "Context provider classes must implement ContextProviderInterface."

# -----------------------------------------------------------------------------
# CLI Commands: Must be final and named *Command
# -----------------------------------------------------------------------------
[[guard.structural.rules]]
on            = "App\\Console\\**"
target        = "class"
must-be-named = "*Command"
must-be-final = true
reason        = "CLI command classes must be final and named *Command."

# -----------------------------------------------------------------------------
# Template Controllers: Must be final, named *Controller, implement interface
# -----------------------------------------------------------------------------
[[guard.structural.rules]]
on            = "App\\Controllers\\**"
target        = "class"
must-be-named = "*Controller"
must-be-final = true
reason        = "Template controller classes must be final and named *Controller."

[[guard.structural.rules]]
on             = "App\\Controllers\\**"
target         = "class"
must-implement = "Studiometa\\Foehn\\Contracts\\TemplateControllerInterface"
reason         = "Template controller classes must implement TemplateControllerInterface."

Running the Guard

bash
# Check all structural rules
mago guard

# Check with detailed output
mago guard --reporting-format rich

# Check specific directory
mago guard app/Blocks/

Example Output

When a convention is violated, Mago provides clear error messages:

error[structural-violation]: Block classes must be final readonly and named *Block.
  ┌─ app/Blocks/Hero/Hero.php:8:1

8 │ class Hero implements AcfBlockInterface
  │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

  = The class `App\Blocks\Hero\Hero` does not match the required name pattern `*Block`.
  = Consider renaming to `HeroBlock`.

Customizing Rules

You can adjust the rules to match your team's conventions:

toml
# Example: Allow non-readonly services
[[guard.structural.rules]]
on            = "App\\Services\\**"
target        = "class"
must-be-named = "*Service"
must-be-final = true
# must-be-readonly = true  # Commented out to allow mutable services
reason        = "Service classes must be final and named *Service."

CI Integration

Add Mago guard to your CI pipeline:

yaml
# .github/workflows/ci.yml
- name: Check conventions
  run: composer exec mago guard

See Also

Released under the MIT License.