Skip to content

CLI Commands

Føhn provides #[AsCliCommand] for creating WP-CLI commands and includes built-in scaffolding commands.

Built-in Commands

Føhn includes scaffolding and discovery management commands:

Scaffolding Commands

bash
# Generate a Timber model (with optional post type)
wp foehn make:model Product
wp foehn make:model Product --post-type

# Generate a post type
wp foehn make:post-type Product

# Generate a taxonomy
wp foehn make:taxonomy ProductCategory --post-types=product

# Generate an ACF block
wp foehn make:acf-block Hero

# Generate a native block
wp foehn make:block Counter --interactive

# Generate a template controller
wp foehn make:controller single-product

# Generate a hooks class
wp foehn make:hooks Seo

# Generate a context provider
wp foehn make:context-provider Header

# Generate a context provider
wp foehn make:context GlobalContext --global
wp foehn make:context ProductContext --templates=single-product,archive-product

# Generate a block pattern
wp foehn make:pattern HeroWithCta

# Generate a shortcode
wp foehn make:shortcode Button

# Generate an ACF field group
wp foehn make:field-group ProductFields --post-type=product
wp foehn make:field-group PageFields --page-template=front-page
wp foehn make:field-group CategoryFields --taxonomy=category

# Generate an ACF options page
wp foehn make:options-page ThemeSettings
wp foehn make:options-page FooterSettings --parent=theme-settings

# Generate a navigation menu
wp foehn make:menu HeaderMenu --location=header
wp foehn make:menu FooterMenu --description="Footer Navigation"

# Generate an image size
wp foehn make:image-size CardImage --width=400 --height=300 --crop
wp foehn make:image-size HeroImage --width=1920 --height=0

Global Options

All scaffolding commands support these options:

bash
--force      # Overwrite existing files
--dry-run    # Preview what would be created without creating

Example with dry-run:

bash
wp foehn make:model Product --post-type --dry-run

Discovery Commands

bash
# List what discovery found, and where it came from
wp foehn discovery:list

# Scan every location and write the cache
wp foehn discovery:generate

# Clear the discovery cache
wp foehn discovery:clear

# Check cache status, per location
wp foehn discovery:status

The cache also fills itself on the first request that finds it missing, and composer install clears it. See Discovery Cache for more details on caching, and Listing what was found for discovery:list.

Rewrite Rules

bash
# Rebuild the rewrite rules, and forget the hash Foehn compares them against
wp foehn rewrite:flush

Foehn flushes on its own when the set of #[AsRewriteRule] declarations changes. This command is for when something else left the rules stale. See Rewrite Rules.

Security Keys

WordPress signs authentication cookies and nonces with eight keys. They live in the environment, so a project keeps them wherever it keeps its other secrets. composer install fills them into .env on a first install, and the generated wp-config.php refuses to serve a production request without them.

bash
# Generate keys for a project that has none
wp foehn salts:generate

# Rotate them — this logs every user out
wp foehn salts:generate --force

Rotating replaces the keys the current cookies were signed with, so every session ends.

Where the keys come from

wp-config.php reads them in this order:

SourceNotes
config/wordpress-salts.config.phpOnly if a project chooses to use a PHP file. Read first, so it wins.
EnvironmentThe default. .env, container variables, a VM's environment, a secret pulled from a vault — anything that reaches PHP.

.env is untracked and managed per install; .env.example lists the eight names empty, so they are visible without being committed. A value that is empty or still starts with change-me- counts as absent.

Because the environment is read, nothing has to end up in a file at all: export the keys from your orchestrator or your vault and the installer leaves them alone. It checks for them before generating.

If you would rather keep them in a PHP file, write one and it takes precedence:

bash
wp foehn salts:generate --path=config/wordpress-salts.config.php

The command warns when that file exists and you rotate .env, since the file is what WordPress would still read.

Custom Commands

Create custom WP-CLI commands with #[AsCliCommand]:

php
<?php
// app/Console/ImportProductsCommand.php

namespace App\Console;

use Studiometa\Foehn\Attributes\AsCliCommand;
use WP_CLI;

#[AsCliCommand(
    name: 'import:products',
    description: 'Import products from CSV file',
)]
final class ImportProductsCommand
{
    /**
     * Import products from a CSV file.
     *
     * ## OPTIONS
     *
     * <file>
     * : Path to the CSV file
     *
     * [--dry-run]
     * : Preview without importing
     *
     * ## EXAMPLES
     *
     *     wp foehn import:products products.csv
     *     wp foehn import:products products.csv --dry-run
     *
     * @param array $args Positional arguments
     * @param array $assocArgs Named arguments
     */
    public function __invoke(array $args, array $assocArgs): void
    {
        $file = $args[0];
        $dryRun = isset($assocArgs['dry-run']);

        if (!file_exists($file)) {
            WP_CLI::error("File not found: {$file}");
        }

        $handle = fopen($file, 'r');
        $headers = fgetcsv($handle);
        $count = 0;

        while (($row = fgetcsv($handle)) !== false) {
            $data = array_combine($headers, $row);

            if ($dryRun) {
                WP_CLI::log("Would import: {$data['name']}");
            } else {
                $this->importProduct($data);
                WP_CLI::log("Imported: {$data['name']}");
            }

            $count++;
        }

        fclose($handle);

        WP_CLI::success("Processed {$count} products");
    }

    private function importProduct(array $data): int
    {
        $id = wp_insert_post([
            'post_type' => 'product',
            'post_title' => $data['name'],
            'post_content' => $data['description'] ?? '',
            'post_status' => 'publish',
        ]);

        if (isset($data['price'])) {
            update_post_meta($id, 'price', $data['price']);
        }

        return $id;
    }
}

Usage:

bash
wp foehn import:products /path/to/products.csv
wp foehn import:products /path/to/products.csv --dry-run

Command with Progress Bar

php
<?php

namespace App\Console;

use Studiometa\Foehn\Attributes\AsCliCommand;
use WP_CLI;

#[AsCliCommand(
    name: 'images:optimize',
    description: 'Optimize all media library images',
)]
final class OptimizeImagesCommand
{
    public function __invoke(array $args, array $assocArgs): void
    {
        $attachments = get_posts([
            'post_type' => 'attachment',
            'post_mime_type' => 'image',
            'posts_per_page' => -1,
            'fields' => 'ids',
        ]);

        $total = count($attachments);

        if ($total === 0) {
            WP_CLI::warning('No images found');
            return;
        }

        $progress = \WP_CLI\Utils\make_progress_bar('Optimizing images', $total);

        foreach ($attachments as $id) {
            $this->optimizeImage($id);
            $progress->tick();
        }

        $progress->finish();
        WP_CLI::success("Optimized {$total} images");
    }

    private function optimizeImage(int $id): void
    {
        // Optimization logic
        wp_update_attachment_metadata($id, wp_generate_attachment_metadata(
            $id,
            get_attached_file($id)
        ));
    }
}

Command with Subcommands

For complex commands, use separate methods:

php
<?php

namespace App\Console;

use Studiometa\Foehn\Attributes\AsCliCommand;
use WP_CLI;

#[AsCliCommand(
    name: 'cache',
    description: 'Manage application cache',
)]
final class CacheCommand
{
    /**
     * Clear all caches.
     *
     * ## EXAMPLES
     *
     *     wp foehn cache clear
     */
    public function clear(): void
    {
        wp_cache_flush();
        WP_CLI::success('Cache cleared');
    }

    /**
     * Show cache statistics.
     *
     * ## EXAMPLES
     *
     *     wp foehn cache stats
     */
    public function stats(): void
    {
        global $wp_object_cache;

        WP_CLI::log('Cache Statistics:');
        WP_CLI::log('  Hits: ' . ($wp_object_cache->cache_hits ?? 'N/A'));
        WP_CLI::log('  Misses: ' . ($wp_object_cache->cache_misses ?? 'N/A'));
    }

    /**
     * Warm up the cache.
     *
     * ## OPTIONS
     *
     * [--post-types=<types>]
     * : Comma-separated post types to warm
     *
     * ## EXAMPLES
     *
     *     wp foehn cache warm
     *     wp foehn cache warm --post-types=post,page,product
     */
    public function warm(array $args, array $assocArgs): void
    {
        $postTypes = isset($assocArgs['post-types'])
            ? explode(',', $assocArgs['post-types'])
            : ['post', 'page'];

        foreach ($postTypes as $type) {
            $posts = get_posts([
                'post_type' => $type,
                'posts_per_page' => -1,
                'fields' => 'ids',
            ]);

            foreach ($posts as $id) {
                get_post($id);
                get_post_meta($id);
            }

            WP_CLI::log("Warmed {$type}: " . count($posts) . ' posts');
        }

        WP_CLI::success('Cache warmed');
    }
}

Usage:

bash
wp foehn cache clear
wp foehn cache stats
wp foehn cache warm --post-types=product

Command with Tables

php
#[AsCliCommand(
    name: 'products:list',
    description: 'List all products',
)]
final class ListProductsCommand
{
    public function __invoke(array $args, array $assocArgs): void
    {
        $products = get_posts([
            'post_type' => 'product',
            'posts_per_page' => -1,
        ]);

        if (empty($products)) {
            WP_CLI::warning('No products found');
            return;
        }

        $items = array_map(fn($p) => [
            'ID' => $p->ID,
            'Title' => $p->post_title,
            'Status' => $p->post_status,
            'Price' => get_post_meta($p->ID, 'price', true) ?: 'N/A',
        ], $products);

        WP_CLI\Utils\format_items(
            $assocArgs['format'] ?? 'table',
            $items,
            ['ID', 'Title', 'Status', 'Price']
        );
    }
}

Dependency Injection

Commands support constructor injection:

php
<?php

namespace App\Console;

use App\Services\ExportService;
use Studiometa\Foehn\Attributes\AsCliCommand;
use WP_CLI;

#[AsCliCommand(
    name: 'export:orders',
    description: 'Export orders to CSV',
)]
final class ExportOrdersCommand
{
    public function __construct(
        private readonly ExportService $export,
    ) {}

    public function __invoke(array $args, array $assocArgs): void
    {
        $file = $this->export->ordersToCSV();
        WP_CLI::success("Exported to: {$file}");
    }
}

Long Description

Provide detailed help with longDescription:

php
#[AsCliCommand(
    name: 'sync:inventory',
    description: 'Sync inventory from external API',
    longDescription: <<<'DOC'
## DESCRIPTION

Synchronizes product inventory levels from the external inventory
management system.

## OPTIONS

[--force]
: Force sync even if recently updated

[--products=<ids>]
: Comma-separated product IDs to sync

## EXAMPLES

    # Sync all products
    wp foehn sync:inventory

    # Force sync specific products
    wp foehn sync:inventory --products=123,456 --force

## NOTES

This command requires API credentials in wp-config.php:
- INVENTORY_API_KEY
- INVENTORY_API_SECRET
DOC,
)]
final class SyncInventoryCommand {}

Attribute Parameters

ParameterTypeDefaultDescription
namestringrequiredCommand name
descriptionstringrequiredShort description
longDescription?stringnullDetailed help (docblock)

See Also

Released under the MIT License.