backstage/laravel-minify-html-middleware
A Laravel middleware that minifies HTML responses on the fly, trimming whitespace and redundant markup to shrink payloads and speed up page delivery without touching your views or blade templates.
composer require backstage/laravel-minify-html-middleware
This is a small, low-traffic package (6 stars, 0 dependents), so spend the 90 seconds to read the source before wiring it up:
cat vendor/backstage/laravel-minify-html-middleware/composer.json # shows the underlying minifier, if any
ls vendor/backstage/laravel-minify-html-middleware/src
grep -rn "class .*Middleware" vendor/backstage/laravel-minify-html-middleware/src
The composer.json is the most useful file — it tells you whether HTML reduction is done by a regex pass in-house or delegated to a library like voku/html-min. That determines every gotcha in the last section.
Laravel 11+/12, in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\Backstage\MinifyHtmlMiddleware\MinifyHtmlMiddleware::class,
]);
})
Laravel 10 and below, append to the web group in app/Http/Kernel.php:
protected $middlewareGroups = [
'web' => [
// ...
\Backstage\MinifyHtmlMiddleware\MinifyHtmlMiddleware::class,
],
];
Use the web group, not a global append(). Global registration also hits API/JSON responses, download responses, and error pages, which is where this class of middleware usually breaks.
curl -s https://your-app.test/ | wc -c # after
curl -s -H 'Accept-Encoding: identity' ... # rule out gzip skewing the number
Then view-source: a page: you should see blank lines removed and indentation between tags collapsed, but <pre> blocks, <script> bodies, and <textarea> content intact. If those are intact, the package's "safe" claim is holding for your markup.
Public, Blade-rendered marketing pages behind web middleware. Leave it off in local — see the dev-tooling gotcha below.
Scope it by group, gate it by config. The pragmatic setup is web-group registration plus an env/config switch, so a staging environment can toggle it without a deploy:
// config/view.php or a package config if one is publishable
'minify_html' => env('MINIFY_HTML', ! app()->isLocal()),
If the package ships a config file, publish it (php artisan vendor:publish --provider=... or --tag=...) and check for a skip-paths / enabled option before writing your own wrapper.
Understand where it sits in the pipeline. Appending to a middleware group places it innermost — it sees the controller's response first on the way out. That is usually what you want for HTML, but it means anything an outer middleware appends to the body afterwards (rare, but some header/footer injectors do this) won't be minified.
Skip the responses you can't safely touch. A defensive subclass is cheap insurance regardless of what the package claims:
public function handle($request, Closure $next)
{
$response = $next($request);
if (! $response instanceof \Illuminate\Http\Response) {
return $response; // StreamedResponse, BinaryFileResponse, JsonResponse
}
if (! str_contains($response->headers->get('Content-Type', ''), 'text/html')) {
return $response;
}
return parent::handle($request, fn () => $response);
}
Adapt to the actual signature — the point is the two guards: concrete Response instance, and text/html content type.
Per-route opt-out for pages where byte count doesn't matter and fidelity does (legal pages with odd markup, print stylesheets, anything with <pre>-heavy content):
Route::get('/press-kit', ...)->withoutMiddleware(MinifyHtmlMiddleware::class);
Inertia / Livewire / Alpine are fine. Inertia only minifies the initial HTML shell (subsequent navigation is JSON). Livewire's DOM updates are JSON payloads, not HTML responses, so they bypass this entirely. Alpine expressions live in attribute values, which a correct HTML minifier does not rewrite.
Cache the minified output, not the source. If you full-page cache, cache after this middleware has run, otherwise you pay the CPU cost on every hit and get no benefit.
Testing. Whitespace-sensitive assertions will now fail. Prefer structural assertions (assertSee('Total: 42', false) with false for escaping, or DOM queries) over comparing raw HTML strings, and don't assert on pretty-printed markup anywhere.
Whitespace-sensitive elements are the whole ballgame. <pre>, <textarea>, <script>, <style>, and inline <code> blocks must survive byte-for-byte. Test explicitly with a page containing each. If the package is regex-based rather than DOM-based, this is where it fails first — and it fails silently in production, not in tests.
Inline JS is the second failure mode. If the underlying minifier touches <script> contents, code relying on ASI (no trailing semicolons) or // comments will break. Confirm <script> bodies are untouched; if not, exclude the paths that carry inline JS or wrap them in <script type="text/plain">-style opt-outs if the minifier honors them.
Never touch Content-Length. HTML responses from Laravel normally don't set it, but if something upstream does, the header and body will disagree after minification. The instanceof Illuminate\Http\Response guard above prevents the worst of this; adding $response->headers->remove('Content-Length') is a belt-and-braces move.
Attribute values must not be rewritten. CSRF hidden inputs, signed URLs with &, x-data expressions, and data-* payloads all live in attributes. A minifier that unquotes attributes or collapses internal whitespace will break form submissions and signed route validation in ways your test suite won't catch. Manual check: log in, submit a form with @csrf, click a signed URL.
Turn it off in local. Laravel Debugbar, Telescope, and Vite's HMR client all inject markup into the response, and minifying it makes debugging genuinely unpleasant — stack traces point at line 1 of a single-line document. ! app()->isLocal() in the enable flag, or ->withoutMiddleware() on local-only debug routes.
Error pages in debug mode. When APP_DEBUG=true, an exception renders a large HTML page. If the middleware is in the web group, it may still run on it. Usually harmless, occasionally the source of a confusing "the error page itself 500s" report. Keeping it disabled locally sidesteps this.
ETag/Last-Modified ordering. Minification is deterministic, so a body-derived ETag stays stable across requests. It only breaks if the ETag is computed from the pre-minified body by an outer middleware. If you see spurious cache misses after enabling, check the order.
Measure the cost on your heaviest page. For a 200KB HTML document, expect single-digit milliseconds. It's negligible at normal traffic, but it is per-request CPU — if you're already fighting response time on a high-traffic page and not caching it, this adds to the pile for a bandwidth win that gzip already largely delivered.
Failure must degrade, not break. If the minifier throws on unusual markup, every page returns 500. Wrap the minification in try/catch and return the original response on failure (and log once). For a package with this little production exposure, that's the difference between a cosmetic feature and an outage.
Weigh build-vs-buy. The full behavior here is plausibly 30 lines of middleware around a well-known minifier. Given 6 stars and 0 dependents, pin the exact version in composer.json, and if the source turns out to be trivially small, consider vendoring the middleware into app/Http/Middleware — you keep the behavior, drop a supply-chain dependency, and gain the extension points the package may not offer.
How can I help you explore Laravel packages today?