Output Filtering
Output filtering is a single hook the Kernel runs on every response, right before it's sent — the one place to rewrite a response's body or headers no matter which controller, error handler, or aspect produced it. Canvas ships one filter, HtmlMinifyFilter, which collapses HTML/CSS whitespace; the hook itself is a plain interface you can implement yourself.
Enabling a filter
Set config/app.php's output_filter key to a class name implementing OutputFilterInterface. The Kernel resolves it once, through the DI container, during startup:
// config/app.php
return [
// ...
'output_filter' => \Quellabs\Canvas\Output\HtmlMinifyFilter::class,
]; The default is null — no filter runs and responses leave the Kernel exactly as the controller or error handler produced them.
security_header_policy, output_filter only accepts a single class, not an ordered array. A filter class that is not a string, doesn't exist, or doesn't implement OutputFilterInterface throws an InvalidArgumentException at startup — before any request is handled — rather than failing per request. If you need several transformations, compose them inside one filter class. The OutputFilterInterface contract
namespace Quellabs\Canvas\Output;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
interface OutputFilterInterface {
/**
* Filters a response in place.
*/
public function filter(Request $request, Response $response): void;
} filter() takes the finished Request and Response and mutates the response directly — there's no return value, and no way to swap in a different response object. Call $response->setContent(), $response->headers->set(), etc. on the instance you're given.
Where it runs in the request lifecycle
The Kernel calls the configured filter from a single point inside handle(), after the default security-header policy has been applied and before a HEAD request's body is stripped:
// Apply security headers
$this->applyDefaultSecurityHeaderPolicyUnlessAlreadyApplied($request, $response);
// Apply output filter
$this->outputFilter?->filter($request, $response);
// HEAD must never carry a body...
if ($request->isMethod('HEAD')) {
$this->suppressBodyForHeadRequest($response);
} This placement means:
- The filter sees every response the Kernel returns — routed controller responses and error pages alike — since
handle()runs it after the try/catch that builds the error response. - It sees the final headers set by the security-header policy, so it can read or override them if needed.
- It runs before
HEADbody suppression, so a filter never has to special-caseHEAD— write content normally and the Kernel strips it afterward. - The Kernel passes every
Responsesubclass through unconditionally, includingStreamedResponseandBinaryFileResponse, whose content isn't a plain string. A filter that only makes sense for ordinary buffered responses has to check for those types itself — see howHtmlMinifyFilterdoes it below.
Built-in filter: HtmlMinifyFilter
Quellabs\Canvas\Output\HtmlMinifyFilter collapses whitespace in buffered HTML responses — and in any inline <style> blocks — while leaving the content of script/style/pre/textarea/svg/math/template/code/xmp elements and HTML comments byte-for-byte untouched.
It skips a response entirely when any of the following apply:
| Condition | Why |
|---|---|
StreamedResponse or BinaryFileResponse | No complete, in-memory body to rewrite |
Status 204, 205, 206, or 304 | These responses don't carry a body that minification applies to |
Content-Encoding set, or Cache-Control: no-transform | The bytes are already compressed, or explicitly marked as not to be altered |
| Empty or non-string body | Nothing to minify |
A Content-Type set to something other than text/html | Only HTML is minified; an explicit JSON/XML/etc. type is left alone |
No Content-Type set and the body doesn't look like HTML | Templates can render HTML before a Content-Type header is assigned, so the filter sniffs the body rather than skipping unconditionally |
When minification actually changes the body, HtmlMinifyFilter updates Content-Length (if present) and removes ETag, Content-MD5, Digest, Content-Digest, and Repr-Digest — validators computed against the original bytes that would otherwise be wrong after rewriting the body.
Writing a custom filter
Because the Kernel resolves the filter class through the DI container, a custom filter can type-hint any service — a logger, the configuration object, a cache — exactly like a controller or aspect:
namespace App\Output;
use Psr\Log\LoggerInterface;
use Quellabs\Canvas\Output\OutputFilterInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class ResponseTaggingFilter implements OutputFilterInterface {
public function __construct(private LoggerInterface $logger) {}
public function filter(Request $request, Response $response): void {
// Tag every response with a correlation id, and log the outcome
$id = bin2hex(random_bytes(8));
$response->headers->set('X-Response-Id', $id);
$this->logger->info('Response sent', [
'id' => $id,
'path' => $request->getPathInfo(),
'status' => $response->getStatusCode(),
]);
}
} // config/app.php
return [
// ...
'output_filter' => \App\Output\ResponseTaggingFilter::class,
]; Since only one filter class runs, guard any transformation the same way HtmlMinifyFilter does — check the response type, status code, and headers before touching the body — so the filter stays safe on responses it was never meant to rewrite (redirects, downloads, streamed output, non-HTML APIs sharing the same application).