TruCMS runs on shared hosting as well as on dedicated servers, so a plugin must be fast without help from a cache and correct when a cache is switched on. This page shows both sides.
| Cache | Holds | Cleared by |
|---|---|---|
| Response cache | Whole public HTML pages | Model changes, settings and theme-option saves, cache:clear, the Settings → Cache buttons |
| Model and key caches | Menus (15 minutes), languages and countries (forever), widget areas, available payment methods (1 hour), reference data | The model's own events (ClearCache trait, observers, versioned keys) |
| Compiled theme CSS | storage/app/public/css/themes/{theme}/app.css | Saving theme options, or Regenerate Theme CSS |
| Template assets | Purged section CSS per page | A version bump whenever a template_assets row changes (reseeding the library, importing a package) |
| Laravel caches | Config, routes and compiled views | php artisan optimize:clear; rebuilt by dotartisan:optimize |
Saving some settings caches your routes
Fields stored in .env trigger dotartisan:optimize, which runs config:cache and route:cache. After that, edits to route files or config files are ignored until you run php artisan optimize:clear. If a new plugin route returns 404 on a site that worked a minute ago, this is the usual reason.
The response cache stores rendered pages and serves them without running controllers. It ships switched off (RESPONSE_CACHE_ENABLED=false); a site owner turns it on in Settings → Cache. Never assume it is on, and never assume it is off.
Only routes that carry the doCache middleware are candidates. Core uses it on the public front routes and the {slug} resolver; Booking on its public catalogue; TruCommerce on the shop and product pages. On top of that, App\Services\ResponseCache\CacheProfile decides per request:
GET responses with a text/html content type are stored. JSON and XHR requests never are.updated_at, with a shorter lifetime (RESPONSE_CACHE_AUTHENTICATED_LIFETIME, 15 minutes).RESPONSE_CACHE_QUERY_LIFETIME). Free-text search parameters (q, s, search, query) are never cached; tracking parameters (utm_*, gclid, fbclid) are ignored.responsecache store, so clearing them never wipes rate limiters or locks.Modules/Testimonials/routes/front.php
Route::get('/', [TestimonialController::class, 'index'])
->middleware('doCache')
->name('index');
Route::post('/', [TestimonialController::class, 'store']) // never on a write route
->name('store');Add doCache only to pages that look the same for every guest. Leave it off account pages, carts, checkouts, wizards, forms that show per-visitor state, and anything that changes data.
Sometimes a cacheable page shows something personal only in certain cases. Tell the profile from the code that adds that data:
use App\Services\ResponseCache\CacheProfile;
// This response belongs to one visitor: render it, never store it.
CacheProfile::keepOutOfCache();
// This page records or shows per-user state: cache it for guests, never for signed-in users.
request()->attributes->set(CacheProfile::GUESTS_ONLY, true);TruCommerce's product page uses GUESTS_ONLY because it records the signed-in customer's visit. Booking's MyBookings component keeps any page it appears on out of the cache entirely.
A cached page can still contain a fresh CSRF token or a fresh cart badge, because replacers rewrite marked fragments when a cached copy is served. The core uses CsrfTokenReplacer and StripCookiesReplacer; TruCommerce adds its CartFragmentReplacer from its provider's register() by appending to config('responsecache.replacers'). Follow that pattern only for tiny fragments that every page shares; for anything larger, keep the page out of the cache.
BaseModelApp\Models\BaseModel uses the ClearCache trait. On created, updated and deleted it forgets the model's cache keys and flushes the response cache. You get this for free. Override two methods when you need to:
class Testimonial extends BaseModel
{
/** Extra keys to forget when a testimonial changes. */
protected function cacheKeys(): array
{
return ['testimonials:published', 'testimonials:count'];
}
/** Return false when changes never affect what visitors see (counters, audit fields). */
protected function shouldClearResponseCache(): bool
{
return true;
}
}For models that do not extend BaseModel (translation models, pivot models, other modules' models, media), register them once in boot():
use App\Services\ResponseCache\ResponseCacheInvalidator;
ResponseCacheInvalidator::flushWhenChanged([
TestimonialTranslation::class,
config('media-library.media_model'), // the configured media class, not Spatie's base class
]);Staff rather than their parent.registerReferenceCacheInvalidation() lists every translation model for this reason.DB::table(), mass update()) fires no model events. Call ResponseCacheInvalidator::flush() yourself afterwards.Setting::save().For heavy reference data, use a versioned key instead of hunting down every cached variant. Booking (BookingReferenceCache) and TruCommerce (StorefrontReferenceCache) do this:
final class TestimonialCache
{
private const VERSION_KEY = 'testimonials:cache-version';
public function remember(string $name, Closure $callback): mixed
{
$version = (int) Cache::get(self::VERSION_KEY, 1);
return Cache::remember("testimonials:{$version}:{$name}", now()->addHour(), $callback);
}
public function flush(): void
{
Cache::forever(self::VERSION_KEY, (int) Cache::get(self::VERSION_KEY, 1) + 1);
}
}Old entries simply expire. Call flush() from the same model events that flush the response cache.
Your provider runs on every request, including those served from the response cache's miss path, admin AJAX calls and Artisan commands. The cheapest code is code that does not run.
'pages' => fn (): array => [...] to SettingsManager::add(). Building field definitions eagerly cost the core about 30 ms of an 80 ms admin request before it was made lazy.register() or boot(). Register class names and closures; resolve data when a page, widget or shortcode actually renders.AdminBooting. Sidebar items, settings sections and menu-builder sources are never built for public visitors.with('translations'); lists with images need the media relation.The test environment disables the response cache (RESPONSE_CACHE_ENABLED=false) and uses the array store. Turn it on inside a test to check your rules; tests/Pest.php has helpers for this.
it('serves the testimonials page from the cache and refreshes it after a change', function (): void {
config([
'responsecache.enabled' => true,
'responsecache.cache.store' => 'array',
'responsecache.debug.enabled' => true, // adds the X-Cache-Status header
]);
$this->get(route('testimonials.index')); // MISS, stored
expect(cacheStatus($this->get(route('testimonials.index'))))->toBe('HIT');
Testimonial::factory()->create(['author_name' => 'Grace Hopper']); // flushes
nextVisitor($this)->get(route('testimonials.index'))->assertSee('Grace Hopper');
});cacheStatus() reads the X-Cache-Status header, cachedResponses() lists what the array store holds, and nextVisitor() starts a fresh session so one test can act as several visitors.