---
url: /foehn-framework/guide/cli-commands.md
---
# 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](/guide/discovery-cache) for more details on caching, and [Listing what was found](/guide/discovery-cache#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](/guide/rewrite-rules#flushing-which-is-the-whole-difficulty).

### Verification

```bash
# Fail a CI job when a WordPress or plugin update raised a diagnostic
wp foehn verify --profile=updates --output=build/foehn-verification.json

# Refuse to finish a deploy on unsafe production configuration
wp foehn verify --profile=production
```

Runs one named release gate and writes a deterministic JSON report. It exits `0` on a pass, `1` on actionable diagnostics or a failed check, and `2` when the gate could not run at all — so a CI job can tell a regression from a broken step. The profile is required and there is no flag to select individual checks. See [Verification](/guide/verification) for both profiles, the report schema, and what each gate cannot observe.

### 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:

| Source                              | Notes                                                                                                                   |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `config/wordpress-salts.config.php` | Only if a project chooses to use a PHP file. Read first, so it wins.                                                    |
| Environment                         | The 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

| Parameter         | Type      | Default    | Description              |
| ----------------- | --------- | ---------- | ------------------------ |
| `name`            | `string`  | *required* | Command name             |
| `description`     | `string`  | *required* | Short description        |
| `longDescription` | `?string` | `null`     | Detailed help (docblock) |

## See Also

* [API Reference: #\[AsCliCommand\]](/api/as-cli-command)
* [WP-CLI Commands Cookbook](https://make.wordpress.org/cli/handbook/guides/commands-cookbook/)
