Discovery Cache
Føhn uses PHP reflection to discover attributes at runtime. While this provides a great developer experience, it can add overhead in production: the scan covers your theme's app directory and the framework package, which is where its Twig extensions and CLI commands come from. The discovery cache stores discovery results to avoid that reflection.
TIP
You do not have to run anything. composer install clears the cache, and the first request that finds it missing writes it — so a deploy warms itself. wp foehn discovery:generate is there for when you would rather pay that first request's cost yourself, before traffic arrives.
Configuration
Enable discovery caching by passing configuration when booting the kernel:
<?php
// functions.php
use Studiometa\Foehn\Kernel;
Kernel::boot(__DIR__ . '/app', [
'discovery_cache' => 'full', // or 'partial', 'none', true, false
]);Cache Strategies
| Strategy | Description |
|---|---|
'full' | Cache all discoveries (vendor + app) - best for prod |
'partial' | Cache only vendor discoveries - good for staging |
'none' | Disable caching - use in development |
true | Alias for 'full' |
false | Alias for 'none' |
Custom Cache Path
By default, cache files are stored in wp-content/cache/foehn/discovery/. You can customize this:
Kernel::boot(__DIR__ . '/app', [
'discovery_cache' => 'full',
'discovery_cache_path' => WP_CONTENT_DIR . '/cache/my-theme/discovery',
]);CLI Commands
Generate Cache
Scan every discovery location and write the result to the cache. Optional — a request that finds the cache missing warms it — but useful when you want the scan to happen on your deploy rather than on the first visitor's page load.
wp foehn discovery:generateOptions:
--strategy=<strategy>- Override configured strategy (full, partial)--clear- Clear existing cache before generating
# Generate with a specific strategy
wp foehn discovery:generate --strategy=full
# Clear and regenerate
wp foehn discovery:generate --clearThe command reports what it found:
Generating discovery cache using 'full' strategy...
Success: Discovery cache generated successfully (12 discoveries cached).
Cached discoveries:
- HookDiscovery: 18 items
- CliCommandDiscovery: 18 items
- TwigExtensionDiscovery: 3 items
- PostTypeDiscovery: 2 items
...Nothing is applied while generating: the command builds and stores, so running it inside a booted request cannot register a hook twice.
Clear Cache
Clear the discovery cache:
wp foehn discovery:clearRun this command when:
- Adding or removing attributed classes
- Changing attribute parameters
- Deploying new code
Check Status
View the current cache status:
wp foehn discovery:statusOutput example:
Discovery Cache Status
======================
Strategy: full
Enabled: Yes
Cache path: /var/www/html/wp-content/cache/foehn/discovery
✓ Studiometa\Foehn\
✓ App\
Locations cached: 2/2
Discovery cache is active and valid.Listing what was found
discovery:status answers how warm the cache is. It does not answer what registered, which is the question behind almost every "my post type is missing".
wp foehn discovery:listPostTypeDiscovery (main) — 2 items
location className implementsConfig attribute
App\ App\Models\Testimonial false AsPostType(name: testimonial, singular: Témoignage, plural: Témoignages)
App\ App\Models\Product false AsPostType(name: product, singular: Produit, plural: Produits, hasArchive: true)
1 discovery with items, 0 empty.
Locations: Studiometa\Foehn\ (cached), App\ (scanned)Three things in that output are the point of the command:
- A discovery that found nothing is listed, not hidden.
PostTypeDiscovery (main) — 0 itemsis an answer; an absent line is not. - Each location says whether it was scanned or restored from the cache. A cache written before your class existed reports zero items and no error, and this is the one line that makes that visible. Clear it with
wp foehn discovery:clear. - Arguments are read back off the attribute instance, so what you see is what was cached rather than what the source says. Arguments still holding their default are left out.
Listing registers nothing — discovery runs, apply() does not.
Options
| Option | Effect |
|---|---|
--discovery=<name> | One discovery. Hook, HookDiscovery and the fully qualified name all match. |
--location=<namespace> | Only items found under this namespace, e.g. --location=App. |
--format=table|json|count | count is one line per discovery; json is the same report, machine-readable. |
# Why is my hook not firing?
wp foehn discovery:list --discovery=Hook --location=App
# A project with thousands of items
wp foehn discovery:list --format=countA discovery of your own appears here with no work: the renderer reflects whatever attribute the item holds. See Custom Discovery.
Deployment Workflow
Basic Deployment
# 1. Deploy your code
git pull origin main
# 2. Install dependencies — this also clears the discovery cache
composer install --no-dev --optimize-autoloaderThe next request warms the cache. Add a third step only if you would rather that request were yours than a visitor's:
# 3. Optional: warm the cache before traffic arrives
wp foehn discovery:generateWith CI/CD
Add to your deployment script:
# GitHub Actions example
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy code
run: rsync -avz ./ user@server:/var/www/html/
- name: Generate discovery cache
run: |
ssh user@server "cd /var/www/html && wp foehn discovery:generate"With Laravel Forge
In your deploy script:
cd /home/forge/example.com
git pull origin main
composer install --no-dev --optimize-autoloader
# Generate the Føhn discovery cache
php wp-cli.phar foehn discovery:generate
# Clear other caches
php wp-cli.phar cache flushEnvironment-Based Configuration
Use environment variables for different environments:
<?php
// functions.php
use Studiometa\Foehn\Kernel;
$cacheStrategy = match (wp_get_environment_type()) {
'production' => 'full',
'staging' => 'partial',
default => 'none',
};
Kernel::boot(__DIR__ . '/app', [
'discovery_cache' => $cacheStrategy,
]);Or use a constant in wp-config.php:
// wp-config.php
define('FOEHN_DISCOVERY_CACHE', 'full');// functions.php
Kernel::boot(__DIR__ . '/app', [
'discovery_cache' => defined('FOEHN_DISCOVERY_CACHE')
? FOEHN_DISCOVERY_CACHE
: 'none',
]);How It Works
Without cache: on each request, Føhn reflects over the classes of every discovery location — your app directory and every installed package that opts into discovery.
With cache: each location's results are stored, and a location that is cached is not scanned at all. This is why
discovery:statusreports how many of them are warm rather than a single yes or no.Warming: a request that had to scan a location writes what it found, so the next one does not. Under
partial, only vendor locations are written, because the app is rescanned every request anyway and the file would never be read. A cache that cannot be written — a read-onlywp-content— is not an error: the page is served, and the scan happens again next time.Invalidation:
composer installandcomposer updatedelete the cache, through the same installer plugin that generates the web root. That is the deploy hook, and it needs no database and no WP-CLI. A cache written by a version of Føhn whose attributes have a different shape is ignored rather than half-restored.
WARNING
Two cases invalidation does not cover. A project that sets FoehnConfig::$discoveryCachePath puts the cache somewhere the installer cannot find, and owns clearing it. And editing a class on a live server without running Composer leaves the cache describing the previous code — run wp foehn discovery:clear.
What's Cached
Everything a discovery found, as the attribute instance that produced it plus the reflection facts that are not in the attribute — the class name, a method name, whether the class implements an interface. Values derived from an attribute are computed when the item is applied, not stored:
- Hook registrations (actions/filters)
- Post types and taxonomies
- Blocks (ACF and native)
- Block patterns
- Context providers
- Template controllers
- REST routes
- Shortcodes
- CLI commands
- Twig extensions
Cache Format
Entries are written by symfony/cache as executable PHP, one per discovery location, so the opcode cache holds them. The files are an implementation detail: read them with discovery:status, and rewrite them with discovery:generate, rather than editing them.
Troubleshooting
Cache Not Working
Check if caching is enabled:
bashwp foehn discovery:statusEnsure the cache directory is writable:
bashchmod -R 755 wp-content/cache/foehnRegenerate the cache:
bashwp foehn discovery:generate --clear
Changes Not Reflected
If your code changes aren't taking effect:
Clear the discovery cache:
bashwp foehn discovery:clearClear PHP opcode cache:
bashwp eval "opcache_reset();"
Development Mode
Always disable caching in development to see changes immediately:
Kernel::boot(__DIR__ . '/app', [
'discovery_cache' => WP_DEBUG ? 'none' : 'full',
]);Performance Impact
| Scenario | First Request | Subsequent Requests |
|---|---|---|
| No cache (development) | ~50-100ms | ~50-100ms |
| Full cache (production) | ~50-100ms | ~5-10ms |
Times are approximate and depend on the number of discovered classes.