Search by

nowo-tech / word-template-bundle

HecFranco

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

Homepage

Documentation

Type:symfony-bundle

pkg:composer/nowo-tech/word-template-bundle

Statistics

Installs: 1 606

Dependents: 0

Suggesters: 1

Stars: 0

Open Issues: 0

v1.9.0 2026-11-03 12:28 UTC

README

CI Packagist Version Packagist Downloads License PHP Symfony GitHub stars Coverage

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) → setValue on 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 as upper(), 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 a bool — see Conditionals by data type for how to derive it from bool, 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 .docx via distinct anchors; one TableRows per table row).
  • HtmlContent → rich fragments (paragraphs, bold/italic, tables inside HTML, etc.) via PHPWord Html::addHtml embedded 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 → setImageValue with optional width/height.
  • listVariables() → unique paint placeholder names (includes fields inside conditionals; omits #if / #endif markers).
  • listConditionalBlocks() → unique conditional block names (e.g. annex from ${#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 .docx compatible with PHPWord. Under FrankenPHP (especially worker mode), process() is bounded by a configurable timeout (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 (Html statics, 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
Expression true Expression false Nested blocks Else pair
One of N Table only when there are rows Functions
One of N Table with rows Table without rows Functions
Named block (PHP) Dates
Named block 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
Invoice rows Nested loops Loop variables

Demo pages (demo/symfony8):

Template form Conditionals (named + expression decisions) Expression examples (Word / PHP / result) Loops
Template form Conditionals page Expression examples Loops

FrankenPHP Friendly Worker Mode

This bundle is FrankenPHP worker mode friendly.

It stays safe in kernel-not-reset workers (scenario B): see docs/FRANKENPHP-WORKER-AUDIT.md.

Versioning

Semantic 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

Additional documentation

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/Unit and tests/Integration (minimal kernel in tests/Fixtures/AppKernel.php; real .docx templates built with PHPWord at runtime).
  • Measured scope — all of src/ (phpunit.xml.dist <source>); no exclusion and no @codeCoverageIgnore.
  • Reports — composer test-coverage / coverage-check write coverage/coverage.xml and the text summary into coverage/ (the coverage-data Docker volume).

License

MIT License.