Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
vendor/
coverage/
.phpunit.result.cache

# Generated class-loader cache (a build artefact, not source)
class-loader-cache/
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@
All notable changes to this project will be documented in this file, per [the Keep a Changelog standard](http://keepachangelog.com/) and will adhere to [Semantic Versioning](http://semver.org/).

## [Unreleased] - TBD
### Added
- Build-time class-cache generation: a `tenup-framework-generate-class-cache` command (installed to `vendor/bin/`) and a `composer generate-class-cache` alias that build the cache in CI without bootstrapping WordPress. See [Build and Deployment](docs/Build-and-Deployment.md) ([#30](https://github.com/10up/wp-framework/issues/30)).
- Hidden admin page (`admin.php?page=tenup-framework-loaders`, `manage_options`) that aggregates every class-loader cache on the site — across all framework copies — and shows each cache's path, status, loaded classes, and an on-demand live-vs-cache staleness check. Admin-only (no front-end overhead) and read-only. Disable with the `tenup_framework_enable_loader_debug` filter or the `TENUP_FRAMEWORK_DISABLE_LOADER_DEBUG` constant. See [Debugging class loaders](docs/Debugging.md).

### Changed
- The class-loader cache is now **read-only at runtime** and opt-in. The framework reads a pre-built cache if present and discovers live otherwise, but never writes one on the server — fixing stale caches that could only be cleared by hand ([#30](https://github.com/10up/wp-framework/issues/30)).
- Bumped the cache identifier so a cache written by an older version is ignored after upgrade rather than served stale.
- `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` now forces live discovery (ignores any shipped cache).

### Removed
- Automatic runtime cache generation and its environment gating — `should_use_cache()`, the `production`/`staging` checks, and the `VIP_GO_APP_ENVIRONMENT` handling. Caching is now produced at build time instead.

## [1.2.0] - 2025-03-20
### Changed
Expand Down
76 changes: 76 additions & 0 deletions bin/tenup-framework-generate-class-cache
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env php
<?php
/**
* Generate the 10up WP Framework class-loader cache for one or more directories.
*
* Runs at build time (typically in CI) WITHOUT bootstrapping WordPress, and writes a
* cache file into each given directory. At runtime the framework reads that file but
* never rewrites it, so the cache can never go stale on a server (see GitHub issue #30).
*
* Usage:
* tenup-framework-generate-class-cache <dir> [<dir> ...]
*
* Pass the same directory (or directories) you pass to
* TenupFramework\ModuleInitialization::init_classes() — usually your plugin/theme `inc/`.
*
* @package TenupFramework
*/

declare( strict_types = 1 );

namespace TenupFramework\Bin;

use TenupFramework\ModuleInitialization;
use Throwable;

// Locate the Composer autoloader whether this runs from within the package itself
// (development) or installed inside a consumer project's vendor directory. Composer 2.2+
// exposes the path via this global from the generated bin proxy; otherwise fall back to
// the known relative locations.
$tenup_autoloader_loaded = false;

if ( isset( $GLOBALS['_composer_autoload_path'] ) && file_exists( (string) $GLOBALS['_composer_autoload_path'] ) ) {
require $GLOBALS['_composer_autoload_path'];
$tenup_autoloader_loaded = true;
} else {
$tenup_autoload_candidates = [
__DIR__ . '/../vendor/autoload.php', // Running from the package itself.
__DIR__ . '/../../../autoload.php', // Installed at vendor/10up/wp-framework/bin.
];

foreach ( $tenup_autoload_candidates as $tenup_autoload_candidate ) {
if ( file_exists( $tenup_autoload_candidate ) ) {
require $tenup_autoload_candidate;
$tenup_autoloader_loaded = true;
break;
}
}
}

if ( ! $tenup_autoloader_loaded ) {
fwrite( STDERR, "Could not locate the Composer autoloader. Run `composer install` first.\n" );
exit( 1 );
}

// Target directories are the CLI arguments (everything after the script name).
$tenup_directories = array_slice( $argv, 1 );

if ( empty( $tenup_directories ) ) {
fwrite( STDERR, "Usage: tenup-framework-generate-class-cache <dir> [<dir> ...]\n" );
fwrite( STDERR, "Pass one or more directories (the same ones passed to ModuleInitialization::init_classes()).\n" );
exit( 1 );
}

$tenup_exit_code = 0;

foreach ( $tenup_directories as $tenup_directory ) {
try {
$tenup_classes = ModuleInitialization::instance()->generate_cache( $tenup_directory );
fwrite( STDOUT, sprintf( "Cached %d class(es) for %s\n", count( $tenup_classes ), $tenup_directory ) );
} catch ( Throwable $tenup_exception ) {
fwrite( STDERR, sprintf( "Failed to generate cache for %s: %s\n", $tenup_directory, $tenup_exception->getMessage() ) );
$tenup_exit_code = 1;
}
}

exit( $tenup_exit_code );
9 changes: 8 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
"role": "Developer"
}
],
"bin": [
"bin/tenup-framework-generate-class-cache"
],
"autoload": {
"psr-4": {
"TenupFramework\\": "src/"
Expand Down Expand Up @@ -52,7 +55,11 @@
"static": [
"Composer\\Config::disableProcessTimeout",
"phpstan --memory-limit=1G"
]
],
"generate-class-cache": "@php bin/tenup-framework-generate-class-cache"
},
"scripts-descriptions": {
"generate-class-cache": "Generate the class-loader cache for one or more directories, e.g. `composer generate-class-cache -- inc/`."
},
"config": {
"allow-plugins": {
Expand Down
17 changes: 12 additions & 5 deletions docs/Autoloading.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,17 @@ add_action( 'plugins_loaded', function () {
} );
```

Environment caching:
- Discovery results are cached only in production and staging environments (per `wp_get_environment_type()`).
- Cache is stored under the directory you pass to `init_classes()`, in a "class-loader-cache" folder (e.g., `YOUR_PLUGIN_INC . 'class-loader-cache'`).
- To refresh: delete that folder; it will be rebuilt automatically.
- Caching is skipped entirely when the constant `VIP_GO_APP_ENVIRONMENT` is defined or when `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` is set to `true`. Use `define( 'TENUP_FRAMEWORK_DISABLE_CLASS_CACHE', true )` in environments that don't support writable file systems.
Class caching (optional, build-time):
- Discovery is fast, but on large codebases you can cache the discovered class list. The cache is **opt-in and produced at build time**: the framework reads it at runtime but never writes it, so it can never go stale on a server.
- With no cache file present (the default), classes are discovered live on every request. This is correct and is the right default for small projects.
- To produce a cache, run the shipped command in your build/deploy pipeline and ship the result as a build artefact:
```bash
vendor/bin/tenup-framework-generate-class-cache YOUR_PLUGIN_INC
# or, via the Composer alias (see Build and Deployment):
composer generate-class-cache -- inc/
```
- Define `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` as `true` to ignore any shipped cache and always discover live (useful for debugging a suspected stale cache).
- See [Build and Deployment](Build-and-Deployment.md) for CI examples and the per-package caching model.

## Defining a Module
```php
Expand Down Expand Up @@ -116,6 +122,7 @@ class YourModule implements ModuleInterface {
## See also
- [Docs Home](README.md)
- [Modules and Initialization](Modules-and-Initialization.md)
- [Build and Deployment](Build-and-Deployment.md)
- [Post Types](Post-Types.md)
- [Taxonomies](Taxonomies.md)
- [Asset Loading](Asset-Loading.md)
190 changes: 190 additions & 0 deletions docs/Build-and-Deployment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# Build and Deployment

This page covers the **build-time class cache** — how to generate it, how to wire it into
CI, and the per-package model the framework assumes.

## Why caching is a build step

Discovering Modules at runtime is fast, but on large codebases you may want to cache the
discovered class list. The framework's cache is deliberately **read-only at runtime**: it
reads a cache file if one is present and discovers live otherwise, but it never writes one.

That single rule removes a whole class of "works locally, not on the server" bugs. A server
that can't write the cache can't hold a stale one, so the only cache that can exist is the
one your build produced for that exact deploy. There is no freshness check, no background
regeneration, and nothing to clear by hand. (For the history, see
[issue #30](https://github.com/10up/wp-framework/issues/30).)

Caching is therefore **opt-in**: do nothing and your project runs uncached, which is correct
and is the right default for a project with a handful of classes. Add the generate step when
you have a performance reason to.

## Generating the cache

The framework ships a standalone command (installed to your project's `vendor/bin/`) that
runs **without bootstrapping WordPress**, so it is safe to run in CI:

```bash
vendor/bin/tenup-framework-generate-class-cache <dir> [<dir> ...]
```

Pass the same directory you pass to `ModuleInitialization::init_classes()` — usually your
plugin/theme `inc/`. It writes `class-loader-cache/class-loader-cache-v2.php` inside each
directory. Multiple directories may be passed in one call.

### Composer alias

For nicer ergonomics, add a one-line script alias to your project's `composer.json`:

```json
{
"scripts": {
"generate-class-cache": "tenup-framework-generate-class-cache inc/"
}
}
```

Then run it with:

```bash
composer generate-class-cache
```

> Composer does not propagate a dependency's scripts into your project, so the alias lives in
> your own `composer.json`. The `vendor/bin` command is the portable entry point either way.

### Bypassing the cache

Define `TENUP_FRAMEWORK_DISABLE_CLASS_CACHE` as `true` (e.g. in `wp-config.php`) to ignore any
shipped cache and always discover live. Useful when debugging a suspected stale or incorrect
cache.

## Gitignore the cache

The cache is a build artefact, not source. Ignore it and regenerate it on every deploy so an
old copy can never linger:

```gitignore
# Generated class-loader cache (built in CI, shipped with the deploy)
**/class-loader-cache/
```

## Wiring it into CI

The generate step always sits **after** `composer install` (it needs the framework and its
dependencies on disk) and **before** the deploy/packaging step (so the cache ships with the
build). `spatie/php-structure-discoverer` and this command are runtime dependencies, so
`composer install --no-dev` keeps them available.

### GitHub Actions

```yaml
name: Deploy
on:
push:
branches: [ main ]

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
- name: Install PHP dependencies
run: composer install --no-dev --prefer-dist --no-progress
- name: Generate the class-loader cache
run: composer generate-class-cache
# or: vendor/bin/tenup-framework-generate-class-cache inc/
- name: Deploy
run: ./bin/deploy.sh # your deploy ships inc/class-loader-cache/ with the build
```

### GitLab CI

```yaml
stages:
- build
- deploy

build:
stage: build
image: php:8.3
script:
- composer install --no-dev --prefer-dist --no-progress
- vendor/bin/tenup-framework-generate-class-cache inc/
artifacts:
paths:
- vendor/
- inc/class-loader-cache/

deploy:
stage: deploy
script:
- ./bin/deploy.sh # ships the artifacts produced by the build stage
```

### CircleCI

```yaml
version: 2.1

jobs:
build-and-deploy:
docker:
- image: cimg/php:8.3
steps:
- checkout
- run:
name: Install PHP dependencies
command: composer install --no-dev --prefer-dist --no-progress
- run:
name: Generate the class-loader cache
command: vendor/bin/tenup-framework-generate-class-cache inc/
- run:
name: Deploy
command: ./bin/deploy.sh # ships inc/class-loader-cache/ with the build

workflows:
deploy:
jobs:
- build-and-deploy:
filters:
branches:
only: main
```

If the build can't run the generate step for some reason, the deploy still works — it just
runs uncached. A broken cache after a build means the build is the thing to fix, not the
server.

## The per-package model

The framework discovers classes from **one directory mapped to one namespace, per Composer
package**. Each plugin or theme that uses the framework is its own unit: its own namespace,
its own `inc/` (or `src/`) directory, its own cache, and it requires the framework via
Composer in its own `composer.json`.

This is why caching is per package rather than per project. A single cache at the project
root could not tell which discovered classes belong to which plugin without scanning
everything — which is exactly the work the cache exists to avoid. So the cache is generated
per package, into the directory passed to that package's `init_classes()`, and shipped inside
that package.

The trade-off is that each package carries its own `vendor/` (a duplicated framework install)
and its own CI generate step, in exchange for domain-focused units that decouple cleanly. If
your build produces several packages, generate each one's cache — either with a call per
package, or by passing every directory to a single invocation:

```bash
vendor/bin/tenup-framework-generate-class-cache \
wp-content/plugins/foo/inc \
wp-content/plugins/bar/inc
```

## See also
- [Docs Home](README.md)
- [Autoloading and Modules](Autoloading.md)
- [Modules and Initialization](Modules-and-Initialization.md)
Loading
Loading