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.

explanation

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.

Unlike 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 HEAD body suppression, so a filter never has to special-case HEAD — write content normally and the Kernel strips it afterward.
  • The Kernel passes every Response subclass through unconditionally, including StreamedResponse and BinaryFileResponse, 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 how HtmlMinifyFilter does 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:

ConditionWhy
StreamedResponse or BinaryFileResponseNo complete, in-memory body to rewrite
Status 204, 205, 206, or 304These responses don't carry a body that minification applies to
Content-Encoding set, or Cache-Control: no-transformThe bytes are already compressed, or explicitly marked as not to be altered
Empty or non-string bodyNothing to minify
A Content-Type set to something other than text/htmlOnly HTML is minified; an explicit JSON/XML/etc. type is left alone
No Content-Type set and the body doesn't look like HTMLTemplates 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).