nowo-tech / word-template-bundle
Symfony bundle: fill .docx Word templates (PHPWord TemplateProcessor) from a context array — scalars, rich HTML, table rows, images.
Package info
github.com/nowo-tech/WordTemplateBundle
Type:symfony-bundle
pkg:composer/nowo-tech/word-template-bundle
Requires
- php: >=8.2 <8.6
- ext-dom: *
- ext-json: *
- ext-libxml: *
- ext-zip: *
- phpoffice/phpword: ^1.4
- psr/log: ^1.1 || ^2.0 || ^3.0
- symfony/cache: ^7.4 || ^8.0
- symfony/clock: ^7.4 || ^8.0
- symfony/config: ^7.4 || ^8.0
- symfony/dependency-injection: ^7.4 || ^8.0
- symfony/expression-language: ^7.4 || ^8.0
- symfony/http-kernel: ^7.4 || ^8.0
- symfony/yaml: ^7.4 || ^8.0
Requires (Dev)
- doctrine/collections: ^2.2
- friendsofphp/php-cs-fixer: ^3.0
- igor-php/igor-php: ^0.10.1
- nowo-tech/phpstan-frankenphp: ^1.0
- phpstan/extension-installer: ^1.0
- phpstan/phpstan: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^11.0
- rector/rector: ^2.0
- symfony/framework-bundle: ^7.4 || ^8.0
Suggests
- nowo-tech/html-to-word-bundle: Optional: build .docx from full HTML when you do not use a Word template (WYSIWYG → DOCX pipeline).
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v1.9.0
- v1.8.0
- v1.7.1
- v1.7.0
- v1.6.1
- v1.6.0
- v1.5.0
- v1.4.3
- v1.4.2
- v1.4.1
- v1.4.0
- v1.3.9
- v1.3.8
- v1.3.7
- v1.3.6
- v1.3.5
- v1.3.4
- v1.3.3
- v1.3.2
- v1.3.1
- v1.3.0
- v1.2.4
- v1.2.3
- v1.2.2
- v1.2.1
- v1.2.0
- v1.1.0
- v1.0.5
- v1.0.4
- v1.0.3
- v1.0.2
- v1.0.1
- v1.0.0
- v0.1.3
- v0.1.2
- v0.1.1
- v0.1.0
- dev-sec-html-image-entities
- dev-req100
This package is auto-updated.
Last update: 2026-11-09 20:15:56 UTC
README
Found this useful? Install from Packagist (
composer require nowo-tech/word-template-bundle) and consider starring WordTemplateBundle on GitHub.
Symfony bundle that fills Microsoft Word .docx templates (PHPWord TemplateProcessor) using a PHP context array:
- Scalars (strings, numbers, booleans, null) →
setValueon placeholders such as${client_name}or${client.city}when you nest arrays (flattened to dot keys). - Expression conditionals (1.8, opt-in) →
${#if total > 1000}…${#endif}: the condition lives in the template and is evaluated against the context (operators,in,matches, Twig-like functions such asupper(),length(),date()), sandboxed — see Conditionals below. - Loops (1.8, opt-in) →
${#for line in lines}…${#endfor}repeats paragraphs or whole table rows per element, with${line.name},${loop.index}and conditions inside — see Loops below. ConditionalBlock→${#if block}…${#endif block}named regions (several sibling and nested blocks supported); show or remove from PHP. Markers split across Word runs are healed before apply. For named blocks you pass abool— see Conditionals by data type for how to derive it frombool,int,float,string,null, arrays, dates, enums, Doctrine relations and request/form data, the "else" and "one of N" patterns, and where markers may go in Word.TableRows→cloneRow+ per-cell#1,#2, … replacements for repeating table lines (several independent tables in one.docxvia distinct anchors; oneTableRowsper table row).HtmlContent→ rich fragments (paragraphs, bold/italic, tables inside HTML, etc.) via PHPWordHtml::addHtmlembedded as a complex block (lists<ul>/<ol>may require extra numbering setup in PHPWord; prefer plain paragraphs or combine with HtmlToWordBundle for full HTML pipelines).ImageSource→setImageValuewith optional width/height.listVariables()→ unique paint placeholder names (includes fields inside conditionals; omits#if/#endifmarkers).listConditionalBlocks()→ unique conditional block names (e.g.annexfrom${#if annex}).listConditionalExpressions()/lintConditionalExpressions()→ list and validate the expression conditionals of a template before merging (e.g. on upload). This bundle does not execute Word VBA macros; “macros” here means template placeholders in the.docxcompatible with PHPWord. Under FrankenPHP (especially worker mode),process()is bounded by a configurabletimeout(default 180s, cooperative deadline +set_time_limit) so a long merge cannot pin a worker forever. See docs/CONFIGURATION.md and docs/DEMO-FRANKENPHP.md (REQ-RUNTIME-001). PHPWord globals touched during a merge (Htmlstatics, output escaping) are saved and restored so the processor stays safe when the kernel is not reset between requests — see docs/FRANKENPHP-WORKER-AUDIT.md.
Conditionals
Two ways to keep or remove part of a document — both in the same template if you like:
| Named block (decided in PHP) | Expression (decided in the template, 1.8 opt-in) | |
|---|---|---|
| In Word | ${#if annex} … ${#endif annex} |
${#if total > 1000} … ${#endif} |
| In PHP | 'annex' => new ConditionalBlock('annex', $hasAnnex) |
'total' => $order->total() |
| Enable | always on | nowo_word_template.conditional_expressions.enabled: true |
| Branches | — (two blocks with opposite decisions) | ${#elseif total > 100} … ${#else} … ${#endif} (1.9) |
In Word (each marker in its own paragraph):
Invoice ${number}
${#if total > 1000 and upper(country) == 'ES'}
Orders above 1,000 EUR ship for free within Spain.
${#endif}
${#if status != 'paid'}
Please pay before ${due_date}.
${#endif}
In PHP:
$document = $processor->process($templatePath, [ 'number' => 'F-2026-001', 'total' => 1500, 'country' => 'es', 'status' => InvoiceStatus::Overdue, // backed enum → 'overdue' 'due_date' => new DateTimeImmutable('2026-11-15'), // date → '2026-11-15' ]);
Result: Invoice F-2026-001 · Orders above 1,000 EUR ship for free within Spain. · Please pay before 2026-11-15.
Expressions use Symfony ExpressionLanguage syntax with no access to PHP functions, constants or objects — only the context values and a whitelist of Twig-like functions (lower, upper, trim, length, replace, round, number_format, join, default, is_defined, now, date, …; your own via a tagged provider). Errors (typos, unknown variables) throw ConditionalExpressionException by default. Word's typographic quotes are handled. Full reference: USAGE — Expression conditionals (operators, data types, functions, nesting, ${#elseif} / ${#else}, one of N, errors) and USAGE — Conditionals by data type for named blocks.
Rendered by the demo (make docx-previews, LibreOffice) — template in Word → generated document:
| Expression true / false | Nested blocks | "Else" pair |
|---|---|---|
![]() |
![]() |
![]() |
| One of N | Table only when there are rows | Functions |
![]() |
![]() |
![]() |
| Named block (PHP) | Dates | |
![]() |
![]() |
Loops
(1.8, opt-in: nowo_word_template.loops.enabled: true) — repeat paragraphs, or whole table rows when the markers sit in rows of their own:
In Word (each | line is a table row):
| # | Item | Amount |
| ${#for line in lines} |
| ${loop.index} | ${line.name} | ${line.amount} |
| ${#endfor} |
| Total | | ${total} |
In PHP:
$document = $processor->process($templatePath, [ 'lines' => [['name' => 'Anchor bolts', 'amount' => 40], ['name' => 'Brackets', 'amount' => 25.5]], 'total' => 65.5, ]);
Result: one row per line (1 | Anchor bolts | 40, 2 | Brackets | 25.5), then Total | | 65.5.
Twig-like loop.index, index0, revindex, first, last, length, parent; ${#for key, value in map}; ${#if …} and nested loops inside the body see the loop variables; iteration caps protect workers. Full reference: USAGE — Loops.
| Invoice rows + conditional row | Nested loops | Loop variables |
|---|---|---|
![]() |
![]() |
![]() |
Demo pages (demo/symfony8):
| Template form | Conditionals (named + expression decisions) | Expression examples (Word / PHP / result) | Loops |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
This bundle is FrankenPHP worker mode friendly.
It stays safe in kernel-not-reset workers (scenario B): see docs/FRANKENPHP-WORKER-AUDIT.md.
Versioning
Quick start
composer require nowo-tech/word-template-bundle:^1.3
Register Nowo\WordTemplateBundle\WordTemplateBundle if needed, then wire your templates and inject WordTemplateProcessorInterface:
use Nowo\WordTemplateBundle\Processor\WordTemplateProcessorInterface; $doc = $this->wordTemplateProcessor->process( '/path/to/template.docx', [ 'title' => 'Contract #42', 'client' => ['name' => 'ACME', 'city' => 'Madrid'], ], ); $bytes = $doc->readContents(); $doc->dispose(); // if the processor used a temp file
Requirements
- PHP 8.2+
- Symfony 7.4+ / 8.x (as in
composer.json) - Extensions:
dom,json,libxml,zip
Development
make up make qa make release-check
Demos (FrankenPHP)
Demos run with FrankenPHP (Caddy + PHP in Docker). docker-compose defaults to APP_ENV=dev, so the entrypoint uses Caddyfile.dev (no PHP worker), and Twig/PHP changes are visible on refresh. Worker mode applies to a production-style setup — see docs/DEMO-FRANKENPHP.md.
From the repository root:
cd demo/symfony8 && cp .env.example .env && make up # Symfony 8, port 8021
Or from demo/: make up (see demo/README.md).
The demo has four pages (the fourth, /loops, shows invoice-style row loops, loop variables, nested loops, maps and empty lists the same way): / fills a template discovered with listVariables() (scalars, HtmlContent, TableRows, nested conditionals, images); /conditionals shows one form field per PHP type, the ConditionalBlock decision each one produces and the expression conditionals of the same template, and downloads the merged .docx; /conditionals/examples shows each expression case as Word text, PHP context and result, with the template and the generated .docx to download. Playwright: make -C demo/symfony8 test-e2e, demo-screenshots, docx-previews.
Documentation
- Installation
- Configuration
- Usage
- Conditionals by data type — bool/int/float/string/null/array/date/enum/Doctrine/request examples, pitfalls, missing blocks, marker placement
- Expression conditionals —
${#if total > 1000}…${#endif}: operators, data types, functions reference, nesting, errors, security - Loops —
${#for line in lines}…${#endfor}: paragraphs, table rows, loop variables, nesting, limits
- Contributing
- Code of Conduct
- Changelog
- Upgrading
- Release
- Security
- Engram
- Spec-driven development
- GitHub Spec Kit
Additional documentation
- GitHub Actions CI requirements
- PSR evaluation (REQ-CS-007)
- FrankenPHP / Docker demo —
demo/symfony8(seedemo/README.md) - FrankenPHP worker audit (kernel not reset)
Tests and coverage
Measured on 2026-10-29 with composer coverage-check (PHP 8.2 + PCOV, same command as the CI Tests & QA job):
| Language | Line coverage | Gate | Command |
|---|---|---|---|
| PHP | 100% (lines of src/, Clover coverage/coverage.xml) |
100% — composer coverage-check fails below it (CI, make release-check) |
make coverage-check |
| TS/JS | N/A (no TypeScript/JavaScript code) | — | — |
| Python | N/A (no Python code) | — | — |
- PHPUnit —
composer test:tests/Unitandtests/Integration(minimal kernel intests/Fixtures/AppKernel.php; real.docxtemplates built with PHPWord at runtime). - Measured scope — all of
src/(phpunit.xml.dist<source>); no exclusion and no@codeCoverageIgnore. - Reports —
composer test-coverage/coverage-checkwritecoverage/coverage.xmland the text summary intocoverage/(thecoverage-dataDocker volume).

















