How to Insert Formatted HTML Content into DOCX Template Placeholders Using PHPWord

I’m working with PHPWord library in my Laravel project to substitute placeholders in DOCX templates. My template has a placeholder called ${content} that needs to be filled with HTML markup like <p>Hi there,<br><br><em>Thanks for joining</em></p>

The issue is that PHPWord treats the HTML as plain text instead of applying the formatting. I need the HTML to display properly with italics, line breaks, and other formatting.

I looked through PHPWord docs but haven’t found a working approach for HTML conversion. Has anyone dealt with this before?

Here’s my current implementation:

<?php

namespace App\Helpers;

use App\Exceptions\DocumentProcessorException;
use App\Traits\Instantiable;
use PhpOffice\PhpWord\Exception\CopyFileException;
use PhpOffice\PhpWord\Exception\CreateTemporaryFileException;
use PhpOffice\PhpWord\Settings;
use PhpOffice\PhpWord\Shared\Html;
use PhpOffice\PhpWord\Shared\XMLWriter;
use PhpOffice\PhpWord\TemplateProcessor;
use PhpOffice\PhpWord\Writer\Word2007\Element\Container;

class DocumentProcessor
{
    use Instantiable;

    public function __construct(private $templateFile, private $resultFile, private $dataFields = [])
    {
        if (pathinfo(parse_url($this->templateFile, PHP_URL_PATH), PATHINFO_EXTENSION) !== 'docx') {
            throw DocumentProcessorException::invalidFileFormat();
        }
    }

    public function process(): string
    {
        $processor = $this->createProcessor($this->templateFile);

        foreach ($this->dataFields as $fieldName => $fieldData) {
            $this->handleField($processor, $fieldName, $fieldData);
        }

        return $this->saveDocument($processor, $this->resultFile);
    }

    protected function createProcessor(string $template): TemplateProcessor
    {
        return new TemplateProcessor($template);
    }

    protected function handleField(TemplateProcessor $proc, string $name, array $info): void
    {
        $type = data_get($info, 'type');
        $content = data_get($info, 'content');

        if ($this->isSignatureType($name, $content)) {
            $proc->setImageValue($name, $content);
        } elseif ($type === 'html') {
            if (empty($content)) {
                $proc->setValue($name, $content);
                return;
            }
            $cleanHtml = $this->cleanHtmlContent($content);

            $document = new \PhpOffice\PhpWord\PhpWord;
            $page = $document->addSection();

            Html::addHtml($page, $cleanHtml);

            $writer = new XMLWriter;
            $elementWriter = new Container($writer, $page, false);
            $elementWriter->write();

            $proc->replaceXmlBlock($name, $writer->getData());
        } elseif ($this->isImageType($type)) {
            foreach ($content as $index => $fileData) {
                $proc->setValue($name, '${'.$name.$index.'}'.'${'.$name.'}');
                $filePath = $this->getFilePath($fileData);
                $imgData = $filePath ? @getimagesize($filePath) : null;

                if ($imgData && in_array($imgData['mime'], ['image/jpeg', 'image/png', 'image/bmp', 'image/gif'])) {
                    $proc->setImageValue($name.$index, $fileData);
                } else {
                    Settings::setOutputEscapingEnabled(true);
                    $proc->setValue($name.$index, $filePath);
                }
            }
            $proc->setValue($name, '');
        } else {
            Settings::setOutputEscapingEnabled(false);
            $content = htmlspecialchars($content);
            $content = preg_replace('~\R~u', '</w:t><w:br/><w:t>', $content);

            $proc->setValue($name, $content);
        }
    }

    protected function cleanHtmlContent(string $html): string
    {
        $html = preg_replace('#<br(?![^>]*\/)>#i', '<br />', $html);
        $html = preg_replace('#<hr(?![^>]*\/)>#i', '<hr />', $html);
        $html = preg_replace('#<img([^>]*)(?<!/)>#i', '<img$1 />', $html);

        return $html;
    }

    protected function isSignatureType(string $name, mixed $content): bool
    {
        return in_array($name, ['user_signature', 'admin_signature']) && $content;
    }

    protected function isImageType(string $type): bool
    {
        return $type === 'image';
    }

    protected function getFilePath(mixed $data): mixed
    {
        return is_array($data) ? data_get($data, 'file_path') : $data;
    }

    protected function saveDocument(TemplateProcessor $proc, string $output): string
    {
        $proc->saveAs($output);
        return $output;
    }
}

Hit the same issue with mixed content templates. Your code looks good, but there’s a timing problem with PHPWord’s XML generation. The Container writer you’re using creates incomplete XML fragments - it doesn’t close all document elements properly. Switch to IOFactory instead. Write the section to a temp document first, then pull the document.xml content between the body tags. Also, your cleanHtmlContent method might be messing with PHPWord’s HTML parser. Html::addHtml works way better with minimal preprocessing. Just let PHPWord handle the HTML normalization. One more thing: put your DOCX placeholder in its own paragraph with nothing else. Any Word formatting on that paragraph will break the XML replacement.

Had this exact problem last year. PHPWord’s Html::addHtml() method gets weird with template processing. You’re creating a new section, which breaks the document structure around your placeholder.

Here’s what fixed it for me - use a hybrid approach. Don’t build the entire section. Instead, extract just the paragraph elements from the HTML conversion. After Html::addHtml() runs, loop through the section elements and grab their XML directly. Then use that XML with replaceXmlBlock.

Also double-check your template. The placeholder needs its own paragraph. Sometimes you have to manually add block markers in Word by inserting ${content} in a separate paragraph. PHPWord gets picky about detecting blocks when they’re mixed with other text.

your html handling looks way too complic8d. just use replaceXmlBlock with a simpler approach - convert html straight to xml fragments instead of building entire sections. also, make sure your template placeholder is wrapped as a ${content} block, not just a variable.