yurikzt/html-cache-page-bundle
Caches fully rendered HTML pages and serves them on repeat requests, skipping template rendering and database queries. Simple to integrate, configurable cache lifetime, and noticeably faster responses for content-heavy PHP sites.
v1.0.0 is the first public release: a basic HTML cache targeting Symfony 6+. That framing matters more than anything else in the release notes — this is a Symfony bundle, not a Laravel package, and the release notes disclose no README, config schema, or API surface. Treat the assessment below as a structured starting point to validate against the source, not as confirmed API documentation.
Where to look first, in this order:
composer.json — the hard constraints. Confirm symfony/http-kernel and symfony/http-foundation are ^6.0, and check for a type: symfony-bundle declaration plus any extra.symfony block. This tells you whether Laravel can pull it in at all.*Bundle.php) and its DependencyInjection/Extension.php — the config tree here is the package's public API.kernel.response, a HttpCache/Store implementation, or a middleware. That class tells you what is actually cached (status codes, content types, methods) and what the cache key is built from.Minimal steps in a Laravel app:
composer require <vendor>/<package>:^1.0
There is no Laravel package discovery for Symfony bundles, so nothing will auto-register. You will hand-wire it:
// config/app.php providers — there will be nothing to add here.
// Instead, bind the underlying cache service yourself:
$this->app->singleton(PageCache::class, fn ($app) => new PageCache(
storage_path('framework/page-cache'),
$app['config']->get('page-cache.ttl', 3600),
));
Before writing any of that, verify compatibility: Laravel 9 and 10 ship Symfony 6 components and are plausible hosts; Laravel 8 pins Symfony 5 and will not install.
First use case: cache fully rendered HTML for anonymous GET requests on public content pages (marketing pages, docs, product listings). That is what "basic HTML cache" implies — whole-response caching, not fragment or query caching. Start with one route, confirm the cache key and TTL behaviour, then widen.
Middleware over the framework bridge. The highest-leverage move is to not register the Symfony bundle in Laravel. Instead, take the one or two classes that do the caching and drive them from a Laravel middleware. Laravel's own Symfony\Component\HttpKernel\HttpCache\HttpCache already exists in the framework, so a bundle that re-wraps it buys you mostly configuration — you can replicate that in ~40 lines and keep full control.
class CacheHtmlResponse
{
public function handle($request, Closure $next)
{
if (! $this->shouldCache($request)) {
return $next($request);
}
$key = $this->keyFor($request);
if ($cached = Cache::get($key)) {
return response($cached)->header('X-Page-Cache', 'HIT');
}
$response = $next($request);
if ($response->isSuccessful() && ! $response->headers->has('Set-Cookie')) {
Cache::put($key, $response->getContent(), now()->addMinutes(60));
}
return $response->header('X-Page-Cache', 'MISS');
}
}
Key generation is the whole design. Build the key from the things that legitimately vary the HTML: path, sorted query string, locale, currency/session-derived display preferences, and any A/B bucket cookie. Never let raw REQUEST_URI through — parameter order and trailing slashes will fragment your cache into uselessness. Hash the normalized tuple.
Invalidation by tag or version, not by flush. The package is "basic," so assume no tag support. Two patterns that survive contact with production:
page-cache:version:{page} and include it in the key. On model save, increment the version. Old entries expire naturally via TTL.saved/deleted.Product::observe(new class {
public function saved(Product $p) { Cache::increment("page-version:product:{$p->id}"); }
});
Warm the cache out-of-band. A queued job that iterates your sitemap and issues internal requests through the same middleware keeps hit-rate high after deploys and invalidations. Pair with a php artisan page-cache:warm command so it is runnable by hand and by the scheduler.
Keep the boundary thin. Wrap the package in one adapter class with get/put/forget. If v1.x stalls or the Symfony coupling becomes painful, you swap the adapter for spatie/laravel-responsecache and change nothing else. Do not let the package's types leak into controllers.
composer why-not will settle it in seconds.php artisan vendor:publish here, and no .env keys.StartSession if your key depends on session state, or before it if you want cache hits for anonymous users to skip session hydration entirely. Either is defensible; picking one by accident is not.Set-Cookie, Cache-Control: no-store/private, or a non-200 status. A basic HTML cache will typically do what you tell it. Leaking a CSRF token or a personalised cart badge into a shared cache is the classic failure mode, and it is silent.php artisan cache:clear will not clear it. If the package writes to its own directory, Laravel's cache commands are irrelevant to it. Add a dedicated page-cache:clear command, or you will discover this during an incident.dd(). An X-Page-Cache: HIT|MISS header (and the resolved key, behind a debug flag) answers 90% of "why is the page stale" questions. Also verify filesystem permissions on whatever directory it writes to — a silent write failure looks exactly like a permanent miss."1.0.0", not "^1.0") until you have read the source and watched the repository for follow-up releases. First releases of small Symfony bundles commonly change constructor signatures and config keys in the 1.x line.Vary-aware keying, no ESI/fragment support, no stale-while-revalidate. If your day-to-day needs any of those, this is a prototype tool — plan the adapter so replacing it is a one-file change.How can I help you explore Laravel packages today?