Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Word Template Bundle Laravel Package

nowo-tech/word-template-bundle

Symfony bundle that generates Word documents from .docx templates. Replace placeholders with dynamic data through a simple service API, making it easy to produce reports, contracts, and mail-merge files in PHP applications and reuse templates across controllers and commands.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and register:

composer require nowo-tech/word-template-bundle

If Flex doesn't auto-register it, add the bundle class (namespace matches the autoload.psr-4 root in the bundle's composer.json) to config/bundles.php:

return [
    // ...
    Nowo\WordTemplateBundle\WordTemplateBundle::class => ['all' => true],
];

Where to look first, in this order:

  1. src/DependencyInjection/Configuration.php — the only authoritative list of config keys. Don't trust blog posts; with 0 stars and no dependents, README and Configuration.php are the entire documentation surface.
  2. src/ root — find the single public service (usually one *TemplateProcessor, *Generator, or *Service class). That class's constructor and public methods are the API.
  3. php bin/console debug:config <bundle_alias> and php bin/console debug:container --show-private | grep -i word to confirm the service id and default config values in your app.
  4. tests/ — if present, the fixtures show the exact placeholder syntax and call signature better than any prose.

First use case — invoice/contract PDF-adjacent output from an existing .docx:

  1. Create templates/word/invoice.docx containing ${invoice_number}, ${customer_name}, ${total}. Type each placeholder in one sitting without formatting it (see Gotchas).
  2. Inject the service and generate:
final class InvoiceController
{
    public function __construct(
        private readonly \Nowo\WordTemplateBundle\WordTemplateService $word, // confirm real id via debug:container
        private readonly string $projectDir,
    ) {}

    public function __invoke(Invoice $invoice): Response
    {
        $path = $this->word->generate(
            $this->projectDir . '/templates/word/invoice.docx',
            [
                'invoice_number' => $invoice->getNumber(),
                'customer_name'  => $invoice->getCustomer()->getName(),
                'total'          => number_format($invoice->getTotal(), 2, ',', ' '),
            ],
            $this->projectDir . '/var/word/invoice-' . $invoice->getId() . '.docx',
        );

        return $this->file($path, 'invoice.docx');
    }
}

The context array is passed straight through to PHPWord's TemplateProcessor, so key naming follows PHPWord conventions, not Twig's — that mental switch is the first thing to internalise.

Implementation Patterns

Keep controllers dumb; hide the bundle behind a domain service. With one maintainer and no dependents, the bundle is a dependency you may need to swap. Write InvoiceDocxGenerator in your app, inject the bundle service into it, and let controllers call your class. Migration later becomes one file.

Build context arrays in a dedicated mapper, never inline. A InvoiceContextBuilder::build(Invoice): array gives you one place to fix escaping, number/date formatting, and null-to-empty-string coercion. Templates should contain zero logic — PHPWord is a string replacer, not a template engine.

Path discipline. Store templates in templates/word/ and generated files in var/word/, both resolved from %kernel.project_dir%. PHPWord resolves relative paths against the process working directory, which differs between bin/console, PHP-FPM, and Messenger workers. Always pass absolute paths.

Stream, then clean up. Don't hand users a growing pile of files:

$tmp = tempnam(sys_get_temp_dir(), 'docx');
$this->word->generate($template, $context, $tmp);

$response = new BinaryFileResponse($tmp);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
$response->deleteFileAfterSend(true);

return $response;

Big documents, many documents → Messenger. PHPWord loads the whole OOXML archive into memory and does string replacement per zip entry. Generating a 50-page contract with images inside a web request will show up in your p99. Push generation to a handler, write the result to Flysystem, and email a link. It also sidesteps the "one PHPWord instance per request" memory-retention issue.

Tables and repeating blocks go through the underlying TemplateProcessor — the bundle exposes it (check the service's public methods for a getProcessor()-style accessor, otherwise decorate the service). The pattern is:

$processor->cloneRow('item', count($items));      // row containing ${item}
foreach ($items as $i => $item) {
    $n = $i + 1;
    $processor->setValue("item.name#{$n}", $item->getName());
    $processor->setValue("item.price#{$n}", $item->getPrice());
}

cloneRow must be called before any setValue touching #1-suffixed keys.

Images use setImageValue('logo', '/abs/path/logo.png') with absolute paths and, ideally, pre-sized assets — PHPWord embeds the raw file, so a 4 MB PNG becomes a 4 MB docx.

Testing. A .docx is a zip. Assert on content, not bytes:

$zip = new \ZipArchive();
$zip->open($generatedPath);
$this->assertStringContainsString('ACME Corp', $zip->getFromName('word/document.xml'));

Run that in one focused functional test after a template change; it catches broken placeholders faster than opening Word.

Config-driven template dir. If the bundle exposes a template base path, set it once in config/packages/*.yaml and let callers pass only the filename — it removes path duplication from every call site.

Gotchas and Tips

Word splits placeholders across XML runs. The single most common failure: ${customer_name} renders fine in Word but PHPWord never replaces it, because Word stored it as <w:t>${customer_</w:t><w:t>name}</w:t>. Cause: editing, spellcheck, autocorrect, or applying formatting mid-placeholder. Fix: retype the placeholder in one go, paste as plain text, or turn off autocorrect while authoring. When debugging a "silent" replacement, unzip the template and grep word/document.xml — if the placeholder isn't contiguous, that's your bug.

Escaping. TemplateProcessor::setValue() injects into XML; values containing &, <, > can produce a corrupt file that Word refuses to open with a vague "unreadable content" error. Sanitise in your context builder (htmlspecialchars($v, ENT_XML1)) or check whether your PHPWord version escapes for you — behaviour has changed across versions. Test with A & B Ltd. and 3 < 5 early.

Unmatched placeholders leak into the output. Anything in the context that no placeholder consumes is silently ignored, and anything in the template missing from the context usually stays as ${foo} in the delivered document. In dev/test, compare $processor->getVariables() against array_keys($context) and fail loudly on a non-empty diff. In production, at minimum log the diff.

Relative paths resolve against CWD. Already mentioned, but it's the second-most-common bug: works in the browser, fails in bin/console (or vice versa).

Memory and long-running workers. PHPWord holds the whole archive open; under Messenger's --time-limit a worker can accumulate. Restart workers periodically, cap document size, and set memory_limit explicitly on the worker transport. Never loop-generate 500 documents in one process.

Concurrent writes to the same output path will corrupt the file. Use tempnam() or a UUID per generation; never var/word/invoice.docx as a shared target.

Header and footer placeholders are handled inconsistently across PHPWord versions — verify on your pinned version before promising a footer with page numbers.

Config caching. After changing bundle config, bin/console cache:clear; in dev, a stale container is a frequent reason a new config key appears to be ignored.

Verify the package metadata before you commit. The advertised last release (2026-10-13) is in the future relative to any normal dependency-audit baseline, and the repo has 0 stars / 0 dependents. Pin an exact version in composer.json rather than a floating constraint, run composer audit, and vendor-lock with a composer.lock you commit. Treat upgrades as you would a major: read the diff of src/ yourself, because there is no downstream corpus to catch regressions.

PHP 8.2 / Symfony 7–8 target. The bundle is declared compatible with modern PHP and Symfony 7/8 — keep your wrapper service final readonly with constructor injection so it stays aligned, and avoid extending bundle classes; decorate the service instead (decorates: in services.yaml). Decoration is the only safe extension point when the upstream class set is small and undocumented.

Debugging checklist when output is wrong: (1) unzip the generated docx, inspect word/document.xml; (2) unzip the template, check placeholder contiguity; (3) var_dump(array_keys($context)) for typos and case mismatches — PHPWord keys are case-sensitive; (4) confirm every value is a string or scalar, since null and objects stringify unpredictably; (5) only then suspect the bundle.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle