---
url: /foehn-framework/guide/discovery-cache.md
---
# 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

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

```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

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

```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](/guide/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

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

## See Also

* [Installation Guide](/guide/installation)
* [CLI Commands](/guide/cli-commands)
* [API Reference: Kernel](/api/kernel)
