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
# 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=0Global Options
All scaffolding commands support these options:
--force # Overwrite existing files
--dry-run # Preview what would be created without creatingExample with dry-run:
wp foehn make:model Product --post-type --dry-runDiscovery Commands
# 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:statusThe 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
# Rebuild the rewrite rules, and forget the hash Foehn compares them against
wp foehn rewrite:flushFoehn 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.
# Generate keys for a project that has none
wp foehn salts:generate
# Rotate them — this logs every user out
wp foehn salts:generate --forceRotating 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:
wp foehn salts:generate --path=config/wordpress-salts.config.phpThe 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
// 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:
wp foehn import:products /path/to/products.csv
wp foehn import:products /path/to/products.csv --dry-runCommand with Progress Bar
<?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
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:
wp foehn cache clear
wp foehn cache stats
wp foehn cache warm --post-types=productCommand with Tables
#[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
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:
#[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) |