beziworld / laravel-docx-templates
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
pkg:composer/beziworld/laravel-docx-templates
Requires
- php: ^8.3
- ext-dom: *
- ext-zip: *
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/filesystem: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/process: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- phpoffice/phpword: ^1.4
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.7|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- phpstan/phpstan-strict-rules: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-10 19:22:19 UTC
README
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$hiddenattributes 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/Htmlablevalues 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) andmake:docx-templateArtisan commands
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 = 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.
- E-mail: biuro@beziworld.eu
- Issues: GitHub issues
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.