Search by

beziworld / laravel-docx-templates

kamcw

Word (.docx) templates for Laravel on PHPWord: filesystem disks, variables, blocks, table rows, images, Eloquent models, safe escaping, downloads and PDF through LibreOffice

Package info

github.com/Beziworld/laravel-docx-templates

Homepage

Issues

pkg:composer/beziworld/laravel-docx-templates

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

v1.0.0 2026-11-10 19:21 UTC

This package is auto-updated.

Last update: 2026-11-10 19:22:19 UTC


README

tests Latest Version License

Fill Word (.docx) templates in Laravel: contracts, invoices, certificates, letters. A Laravel layer over PHPWord's TemplateProcessor:

  • templates on any filesystem disk (local, S3, ...), output stored on a disk, saved locally or returned as a download or inline response (Responsable, so a route can return the document directly)
  • values with dot notation (${customer.address.city}), blocks that are kept, removed or repeated, table rows cloned per item, images (local file, disk or bytes) and generated tables
  • data from arrays, Eloquent models (through toArray() keys, so $hidden attributes never reach a document), collections and plain objects
  • typed template definitions: one class per document type with typed constructor arguments, required placeholders and a file name
  • Blade-like escaping: every value is XML-escaped like {{ }}; Raw / Htmlable values are inserted as is, like {!! !!}; forbidden control characters removed; new lines and tabs become Word breaks
  • strict mode: rendering fails when placeholders are left unresolved, listing them
  • optional PDF conversion with a configured LibreOffice binary: argument list without a shell, timeout, an isolated user profile per conversion, testable with Process::fake()
  • docx:variables (list the placeholders of a template) and make:docx-template Artisan commands

Polska wersja dokumentacji

Why this package

PHPWord has 49 million downloads, but its Laravel wrappers are small. The most downloaded ones on Packagist (November 2026): novay/laravel-word-template 22k, santwer/exporter 3.7k, the rest below 1k.

santwer/exporter is a capable package built around export classes with concerns (in the style of Laravel Excel), with charts, checkboxes, batch exports and PDF through LibreOffice. It resolves templates from absolute paths or storage_path(). This package takes a different route:

santwer/exporter this package
Template location absolute path or storage_path() any filesystem disk, local path or bytes
Output download, file, PDF disk (store()), local file, download, inline, Responsable, PDF
Document definition export class with concerns TemplateDefinition with typed constructor, required() placeholders
Unresolved placeholders not documented strict mode fails and lists them
Eloquent hidden attributes not documented never rendered (toArray() keys)
PDF in tests needs LibreOffice Process::fake()
Charts, checkboxes, batch exports yes no

novay/laravel-word-template is a fluent wrapper with values, images, loops, watermarks and merging, reading templates from paths. enflow/document-replacer (39k, framework-agnostic) replaces values and converts through unoserver.

Installation

composer require beziworld/laravel-docx-templates
php artisan vendor:publish --tag=docx-templates-config   # optional

Requirements: PHP 8.3+, Laravel 12 or 13, ext-zip, ext-dom. LibreOffice only for PDF.

DOCX_TEMPLATES_DISK=local            # disk with the templates
DOCX_TEMPLATES_PATH=templates        # directory on that disk
DOCX_TEMPLATES_OUTPUT_DISK=          # disk for store(); empty = default disk
DOCX_TEMPLATES_STRICT=true
DOCX_TEMPLATES_SOFFICE=/usr/bin/soffice
DOCX_TEMPLATES_PDF_TIMEOUT=120
DOCX_TEMPLATES_TMP=                  # working directory; empty = system temp

Template syntax

Placeholders use the PHPWord syntax, typed in Word as plain text:

In the template Filled with
${number}, ${customer.address.city} a value (dot notation reads nested data)
${discount} ... ${/discount} (each marker in its own paragraph) a block: kept, removed or repeated
${item.name} in a table row the row is repeated per item
${logo} or ${logo:120:60} an image
${summary} alone in a paragraph a generated table

Word sometimes splits text into several runs while you type; PHPWord repairs most split placeholders. Run php artisan docx:variables invoice.docx to see the placeholders the package finds.

Filling a template

use Beziworld\DocxTemplates\Data\Image;
use Beziworld\DocxTemplates\Data\Table;
use Beziworld\DocxTemplates\Facades\DocxTemplates;

$document = DocxTemplates::template('invoice.docx')       // templates/invoice.docx on the template disk
    ->with($invoice->load('customer'))                     // Eloquent model, array, collection or object
    ->set('note', "Payment within 14 days\nThank you")    // one value, dot notation allowed
    ->block('discount', $invoice->discount > 0)            // keep or remove a block
    ->block('payments', $invoice->payments)                // repeat a block per item: ${amount} or ${payments.amount}
    ->rows('item.name', $invoice->items)                   // repeat the row with ${item.name}, ${item.qty}, ...
    ->image('logo', Image::fromDisk('logo.png', 'public')->size(120, 40))
    ->table('summary', Table::make(['Net', 'VAT', 'Gross'], [[$net, $vat, $gross]]))
    ->filename("invoice-{$invoice->number}.docx")
    ->render();

$document->store('invoices/');                 // output disk, returns "invoices/invoice-....docx"
$document->store('archive/x.docx', 's3');      // another disk
$document->save(storage_path('app/x.docx'));   // local path
return $document->download();                  // or ->inline(), or return $document itself
$document->contents();                         // bytes

Shortcuts on the template: ->store(), ->save(), ->download(), ->pdf().

Lists and booleans in the data fill blocks automatically: with ['discount' => false] the ${discount}...${/discount} block disappears, with a list it is repeated. An empty list removes a block or a table row.

Other template sources:

DocxTemplates::template('contracts/nda.docx', 's3');
DocxTemplates::fromPath(resource_path('templates/nda.docx'));
DocxTemplates::fromContents($uploadedBytes, 'nda.docx');

Values and escaping

Value Written as
string, int, float escaped text (&, <, >, quotes), like Blade {{ }}
null empty
bool booleans.true / booleans.false from the config (1 / empty, like Blade)
DateTimeInterface (also Eloquent date casts) date_format from the config
backed enum / unit enum value / name
Stringable its string
Raw::xml('...'), DocxTemplates::raw(), any Htmlable inserted without escaping, like {!! !!}
array or list only through blocks, rows or tables; otherwise InvalidValue

Characters that XML 1.0 forbids are removed and invalid UTF-8 is replaced, so user input cannot corrupt the document. New lines become <w:br/> and tabs <w:tab/> (line_breaks in the config). PHPWord's own global output escaping setting is left untouched and restored after rendering.

Raw markup goes inside a w:t element, so it usually closes and reopens the run, for example Raw::xml('</w:t><w:br/><w:t>'). Never wrap user input in Raw.

Eloquent models

with($model) reads the keys of $model->toArray(), which means visible attributes, $appends accessors and loaded relations, and takes their values from getAttribute() so that casts keep their type (dates are formatted with date_format). Attributes in $hidden are never available to a template. Load the relations a template needs before rendering ($invoice->load('customer', 'items')).

Template definitions

One class per document type keeps the template, the data mapping and the requirements together:

php artisan make:docx-template InvoiceDocument --template=invoices/invoice.docx
namespace App\Documents;

use App\Models\Invoice;
use Beziworld\DocxTemplates\TemplateDefinition;

final class InvoiceDocument extends TemplateDefinition
{
    public function __construct(private readonly Invoice $invoice) {}

    public function template(): string
    {
        return 'invoices/invoice.docx';
    }

    public function data(): mixed
    {
        return $this->invoice->load('customer');
    }

    public function rows(): array
    {
        return ['item.name' => $this->invoice->items];
    }

    public function blocks(): array
    {
        return ['discount' => $this->invoice->discount > 0];
    }

    public function required(): array
    {
        return ['number', 'customer.name', 'item.name'];   // fails early when the template drifts
    }

    public function filename(): ?string
    {
        return 'invoice-' . $this->invoice->number . '.docx';
    }
}
Route::get('/invoices/{invoice}/docx', fn (Invoice $invoice) => DocxTemplates::render(new InvoiceDocument($invoice)));

DocxTemplates::make(new InvoiceDocument($invoice))->set('note', 'Copy')->pdf()->store('invoices/');

Other hooks: disk() (template disk) and strict().

PDF

$pdf = DocxTemplates::render(new InvoiceDocument($invoice))->pdf();   // RenderedDocument with application/pdf
$pdf->store('invoices/');
return $pdf->inline();

DocxTemplates::document($bytes, 'upload.docx')->pdf();               // convert an existing document

The converter runs soffice --headless --norestore --nolockcheck --nodefault -env:UserInstallation=<temporary profile> --convert-to pdf --outdir <temporary directory> <file> through Laravel's Process with the configured timeout. The command is an argument list (no shell); the temporary profile is removed afterwards, so parallel queue workers do not share one LibreOffice instance. A non-zero exit code, a timeout or a missing output file raise ConversionFailed with LibreOffice's error output. Bind your own Beziworld\DocxTemplates\Pdf\PdfConverter to use another engine (Gotenberg, unoserver).

In tests:

Process::fake(function (PendingProcess $process) {
    $outdir = $process->command[array_search('--outdir', $process->command) + 1];
    file_put_contents($outdir . '/document.pdf', '%PDF-1.7');

    return Process::result();
});

Artisan

php artisan docx:variables invoice.docx            # placeholders and blocks of a template on the disk
php artisan docx:variables ./draft.docx --local
php artisan make:docx-template InvoiceDocument --template=invoices/invoice.docx

Errors

Exception When
TemplateNotFound the template is missing on the disk or path, or is not a .docx (ZIP with word/document.xml)
UnresolvedPlaceholders strict mode and placeholders left; placeholders() lists them
TemplateMismatch a placeholder from require() / required() is missing in the template
InvalidValue a value without a text form, an array used as a value, an image that is missing or not an image
RenderingFailed PHPWord failed, a row anchor is missing, a file cannot be written
ConversionFailed LibreOffice failed, timed out or wrote no PDF
InvalidConfiguration invalid timeout or temporary directory

All exceptions extend Beziworld\DocxTemplates\Exceptions\DocxTemplatesException.

Limits

  • Placeholders inside headers and footers are filled as values and images; blocks and table rows work in the document body only (PHPWord limitation).
  • Nested blocks (a block inside a repeated block) are not supported.
  • Templates are trusted input: they are authored by you, not uploaded by users.
  • PDF output quality depends on LibreOffice and the fonts installed on the server.

Development

composer install
composer ci   # pint --test, phpstan (level 8), pest

Test templates are generated at runtime with PHPWord; LibreOffice is faked with Process::fake().

Author

Created and maintained by Kamil Ćwiertnia, beziworld.eu.

Need an integration? Get in touch through beziworld.eu.

License

MIT. See LICENSE.md. PHPWord is licensed under LGPL-3.0 by its authors. This package is not affiliated with or endorsed by the PHPOffice project, Microsoft or The Document Foundation.