Building Document Studio: a PDF template builder that doesn't trust anyone by default

php dev.to

Every agency project has the same recurring ticket: "can you change the layout on the quote PDF?" The template lives in a Blade file, so the answer is always "sure, give me twenty minutes" — twenty minutes that repeat every time a client wants a different font, a different line, a different logo position. Document Studio is my attempt at deleting that ticket: a Filament plugin that lets the end user design the PDF template, inside the same admin panel they already use, with a live preview and nothing that looks like code.

It's free, MIT-licensed, and the goal was never revenue — it's the first public plugin under my name, and I wanted it to be something I'd be comfortable pointing people to.

The one rule I didn't compromise on

A template builder that lets users insert dynamic data has an obvious failure mode: someone drags in {{ user.password }} or {{ order.internalNotes }}, and now a "customer-facing PDF" is leaking things it shouldn't. Most of the plugins I looked at before building this one solve merge fields by exposing the entire model — every attribute becomes a valid tag, and it's on you to notice if that includes something sensitive.

Document Studio takes the opposite default. A host app writes one class per model, and only what's declared in it is ever reachable from a template:

class OrderDataSource implements DocumentDataSource
{
    public static function model(): string
    {
        return Order::class;
    }

    public function fields(): array
    {
        return [
            'customer_name' => [
                'label' => 'Customer name',
                'resolver' => fn (Order $order): string => $order->customer->name,
            ],
        ];
    }

    public function collections(): array
    {
        return [
            'line_items' => [
                'label' => 'Line items',
                'columns' => ['name' => 'Item', 'qty' => 'Qty'],
                'resolver' => fn (Order $order): iterable => $order->lines
                    ->map(fn ($line) => ['name' => $line->name, 'qty' => $line->qty]),
            ],
        ];
    }

    public function sample(): Order { /* … */ }
}
Enter fullscreen mode Exit fullscreen mode

No Blade::render() on user-typed text, no dot-notation walking an Eloquent model. A merge tag is an inert placeholder ({{field:customer_name}}) that the renderer resolves against this whitelist — and if you stop declaring a field, it just goes blank in old documents instead of throwing. Disabling something must never break a template that already shipped.

dompdf, on purpose

I picked dompdf over Browsershot or mPDF specifically because it's limited. It's pure PHP — nothing to install on the server, no headless Chrome binary to keep patched, no extra container. The tradeoff is that its CSS support is stuck somewhere around 2010: no reliable flexbox, no grid. Every block's HTML is written dompdf-first — table layouts, conservative CSS — which sounds like a constraint but turned out to be a feature: it keeps the print output predictable, and it means the whole rendering pipeline is testable with Pest — snapshot the HTML, assert a PDF comes out, done. No browser to spin up in CI.

A block is two faces of the same class

The editor side and the print side of a block are the same object, on purpose — one class, two methods:

abstract class DocumentBlock
{
    abstract public static function make(): Builder\Block;   // the editor field
    abstract public function render(array $data, RenderContext $ctx): string;  // the print HTML
}
Enter fullscreen mode Exit fullscreen mode

Three blocks ship in v1 — heading, paragraph (with merge fields), and a line-item table bound to a DocumentDataSource collection. A host app can add its own by implementing this contract, and register it with one line in a service provider. The set of blocks a panel can offer is intentionally a subset of what the renderer can render — narrowing what's available in the editor must never break a document that already uses a block you've since disabled.

The live preview, and a bug I didn't expect

The feature I'm most attached to is the preview panel: it renders the unsaved form state against DocumentDataSource::sample(), live, next to the block editor. You drag a table in, pick a collection, and the right-hand panel already shows what the PDF will look like — before you've saved anything.

Building it exposed a genuinely interesting Filament internal: the Builder field has a performance optimization where, after you add, delete, or reorder a block, it partially re-renders — only that field, not its siblings in the schema. Which meant reordering two blocks silently left the preview showing the old order, because nothing told it to refresh. The fix was one line — ->partiallyRenderAfterActionsCalled(false) on the Builder field — but finding it meant reading Filament's own source to understand why a Placeholder sitting right next to the editor wasn't reacting to changes inside it.

What's next

The next real gap to close is versioning — right now editing a template overwrites it, with no history. It's the single feature I'd want if I were the one asking a client to trust their invoice template to this. After that: a couple of starter layouts you can load with one click (an easy, low-cost way to make the first five minutes with the plugin less blank-page), and actually exposing paper size and margins in the editor — they exist in the renderer already, they're just not wired to a form field yet.

If you build on Filament and any of this is useful — the plugin is on GitHub, MIT-licensed, composer require tommasomusetti/filament-doc-studio. Issues and feedback are genuinely welcome; this is the kind of project that only gets better with someone else's use case breaking an assumption I didn't know I'd made.

Source: dev.to

arrow_back Back to Tutorials