wyrihaximus/html-compress
A lightweight PHP package that compresses HTML output by removing unnecessary whitespace, comments, and redundant markup. It helps reduce response size, improve page load times, and save bandwidth with minimal integration effort.
Install the package and verify the factory works:
composer require wyrihaximus/html-compress
The package has no Laravel service provider and no auto-discovery — it is a plain PHP library. You get two entry points from WyriHaximus\HtmlCompress\Factory:
use WyriHaximus\HtmlCompress\Factory;
$compressor = Factory::construct(); // DOM-based, thorough, slower
// $compressor = Factory::constructFast(); // regex/string based, much faster, less safe
$html = $compressor->compress($html);
Both return an implementation of WyriHaximus\HtmlCompress\Compressor (compress(string $html): string). That interface is the only thing you should depend on in your app code.
Where to look first: the Factory class (to see which compressor you get), the Compressor interface, and the package's test suite (to understand what the DOM-based pass actually rewrites — comments, whitespace, quotes, optional tags).
First use case: compress the final HTML response once, globally, via middleware. Register a singleton so you build the compressor once per request lifecycle:
// app/Providers/AppServiceProvider.php
use WyriHaximus\HtmlCompress\Compressor;
use WyriHaximus\HtmlCompress\Factory;
public function register(): void
{
$this->app->singleton(Compressor::class, fn () => config('html-compress.fast')
? Factory::constructFast()
: Factory::construct());
}
// config/html-compress.php
return [
'enabled' => env('HTML_COMPRESS', ! app()->isLocal()),
'fast' => env('HTML_COMPRESS_FAST', false),
];
Then a single middleware appended to the web group:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use WyriHaximus\HtmlCompress\Compressor;
class CompressHtml
{
public function __construct(private readonly Compressor $compressor) {}
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! $this->shouldCompress($request, $response)) {
return $response;
}
$response->setContent($this->compressor->compress($response->getContent()));
$response->headers->remove('Content-Length');
return $response;
}
private function shouldCompress(Request $request, Response $response): bool
{
if (! config('html-compress.enabled')) return false;
if ($request->headers->has('X-No-Compress')) return false;
if ($response instanceof \Symfony\Component\HttpFoundation\StreamedResponse) return false;
if ($response instanceof \Symfony\Component\HttpFoundation\BinaryFileResponse) return false;
if ($response->isRedirection() || ! $response->isSuccessful()) return false;
if (! str_contains(strtolower($response->headers->get('Content-Type', '')), 'text/html')) return false;
return true;
}
}
Register it last in the web group (Laravel 11+ bootstrap/app.php → $middleware->web(append: [...])) so it sees the fully rendered response, and keep an escape hatch (X-No-Compress) for debugging.
Middleware vs. response macro vs. Blade. Middleware is the right default: one place, all HTML responses, no view changes. Use a response macro only if you need per-route opt-in:
use Illuminate\Http\Response;
Response::macro('compressed', function (string $content, int $status = 200) {
return response(app(\WyriHaximus\HtmlCompress\Compressor::class)->compress($content), $status)
->header('Content-Type', 'text/html; charset=UTF-8');
});
Avoid a Blade directive/ob_start wrapper — it interacts badly with @extends, cached views, and streamed output.
Compress once, cache the result. Compression costs CPU per request. For stable pages, compress at the cache layer instead of every response:
$html = Cache::remember("page:{$slug}", now()->addHour(), function () use ($slug) {
return app(Compressor::class)->compress(view('pages.show', ['page' => Page::find($slug)])->render());
});
return response($html)->header('Content-Type', 'text/html; charset=UTF-8');
This pairs well with full-page response caching and means the middleware becomes a no-op for hot paths (the content is already minified; a second pass is wasted work — see Gotchas).
Pick the compressor per route type. constructFast() is fine for large marketing pages with well-formed HTML; use the DOM-based construct() where markup correctness matters (forms, SVG, email templates rendered to HTML). Expose it via config rather than branching in controllers.
Keep it out of JSON/API and framework tooling. The Content-Type guard handles most of it, but also skip when app()->runningUnitTests() is irrelevant — tests should exercise the real path — and skip for Telescope/Debugbar routes, which inject their own markup.
Testing. Compression changes whitespace and attribute quoting, so assert on meaning, not bytes:
it('serves compressed html', function () {
$this->get('/pricing')
->assertOk()
->assertHeader('Content-Type', 'text/html; charset=UTF-8')
->assertSee('Pricing', false)
->assertDontSee('<!-- raw marker -->');
});
Add one test that hits a page containing <pre>, <textarea>, inline <script>, and Alpine/Livewire attributes — that's where regressions hide.
Decorator pattern for extension. Since everything goes through the Compressor interface, wrap it to add logging, timing, or a no-op in debug:
final class ConditionalCompressor implements Compressor
{
public function __construct(private Compressor $inner) {}
public function compress(string $html): string
{
return app()->hasDebugModeEnabled() ? $html : $this->inner->compress($html);
}
}
Bind that in the container and the rest of your code never changes.
<pre>, <textarea>, and between inline elements (<span>a</span> <span>b</span>). Always render a smoke-test page and diff the output visually before enabling globally.//, <!--), template literals, or </script> inside strings can be mangled. If your pages carry inline <script> or CSP hashes/nonces, either disable JS/CSS compression options or use constructFast() and verify. CSP hashes will break the moment whitespace changes — prefer nonces.x-data, @click), Livewire (wire:model), and Vue interpolations often contain quotes, > and <. Test these pages specifically; DOM parsers are the usual culprit when an attribute "mysteriously" becomes empty.Content-Length after rewriting. Symfony doesn't always recompute it, and a stale value truncates the body behind nginx/Apache. Also skip StreamedResponse and BinaryFileResponse — getContent() is false for those and you'll throw.CompressHtml last in the stack, and accept that Debugbar output won't be minified.gzip/brotli in nginx/Caddy and keep HTTP caching headers. Compression is a CPU-for-bytes trade, and brotli usually wins bigger.composer.json, read the changelog on upgrades, and keep the Compressor interface as your only coupling point in app code — swapping in voku/html-min or a custom regex pass later should be a one-line container rebind.strlen($before) vs strlen($after) per request, and expose ?no-compress=1 (checked in middleware) so you can diff raw vs. compressed output in the browser when a layout breaks.How can I help you explore Laravel packages today?