Skip to content

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
<?php
// functions.php

use Studiometa\Foehn\Kernel;

Kernel::boot(__DIR__ . '/app', [
    'discovery_cache' => 'full',  // or 'partial', 'none', true, false
]);

Cache Strategies

StrategyDescription
'full'Cache all discoveries (vendor + app) - best for prod
'partial'Cache only vendor discoveries - good for staging
'none'Disable caching - use in development
trueAlias for 'full'
falseAlias for 'none'

Custom Cache Path

By default, cache files are stored in wp-content/cache/foehn/discovery/. You can customize this:

php
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.

bash
wp foehn discovery:generate

Options:

  • --strategy=<strategy> - Override configured strategy (full, partial)
  • --clear - Clear existing cache before generating
bash
# Generate with a specific strategy
wp foehn discovery:generate --strategy=full

# Clear and regenerate
wp foehn discovery:generate --clear

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

bash
wp foehn discovery:clear

Run this command when:

  • Adding or removing attributed classes
  • Changing attribute parameters
  • Deploying new code

Check Status

View the current cache status:

bash
wp foehn discovery:status

Output 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".

bash
wp foehn discovery:list
PostTypeDiscovery (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 items is 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

OptionEffect
--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|countcount is one line per discovery; json is the same report, machine-readable.
bash
# 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=count

A discovery of your own appears here with no work: the renderer reflects whatever attribute the item holds. See Custom Discovery.

Deployment Workflow

Basic Deployment

bash
# 1. Deploy your code
git pull origin main

# 2. Install dependencies — this also clears the discovery cache
composer install --no-dev --optimize-autoloader

The next request warms the cache. Add a third step only if you would rather that request were yours than a visitor's:

bash
# 3. Optional: warm the cache before traffic arrives
wp foehn discovery:generate

With CI/CD

Add to your deployment script:

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

bash
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 flush

Environment-Based Configuration

Use environment variables for different environments:

php
<?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:

php
// wp-config.php
define('FOEHN_DISCOVERY_CACHE', 'full');
php
// functions.php
Kernel::boot(__DIR__ . '/app', [
    'discovery_cache' => defined('FOEHN_DISCOVERY_CACHE')
        ? FOEHN_DISCOVERY_CACHE
        : 'none',
]);

How It Works

  1. 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.

  2. With cache: each location's results are stored, and a location that is cached is not scanned at all. This is why discovery:status reports how many of them are warm rather than a single yes or no.

  3. 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-only wp-content — is not an error: the page is served, and the scan happens again next time.

  4. Invalidation: composer install and composer update delete 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

  1. Check if caching is enabled:

    bash
    wp foehn discovery:status
  2. Ensure the cache directory is writable:

    bash
    chmod -R 755 wp-content/cache/foehn
  3. Regenerate the cache:

    bash
    wp foehn discovery:generate --clear

Changes Not Reflected

If your code changes aren't taking effect:

  1. Clear the discovery cache:

    bash
    wp foehn discovery:clear
  2. Clear PHP opcode cache:

    bash
    wp eval "opcache_reset();"

Development Mode

Always disable caching in development to see changes immediately:

php
Kernel::boot(__DIR__ . '/app', [
    'discovery_cache' => WP_DEBUG ? 'none' : 'full',
]);

Performance Impact

ScenarioFirst RequestSubsequent 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.

See Also

Released under the MIT License.