Programação Por Equipe DevBras

Capa do artigo: Gerar XML em PHP sem concatenar: DOMDocument, XMLWriter e a armadilha do &

Gerar XML em PHP sem concatenar: DOMDocument, XMLWriter e a armadilha do &

Monte XML com DOMDocument ou XMLWriter para que acento, aspas e & virem conteúdo, evite a armadilha do createElement com valor e valide contra o XSD antes de enviar.

O parceiro de logística rejeita o arquivo de pedidos com "erro de parse na linha 40". Na linha 40 está o cliente "Silva & Filhos Ltda", inserido numa tag montada com o operador ponto. Em XML, o & não é texto: é o começo de uma referência de entidade, e a partir dali o documento é inválido. A resposta curta: não monte XML como string; crie os nós com DOMDocument (ou XMLWriter, para arquivos grandes) e coloque o texto por um método que escapa sozinho. Aí acento, aspas, < e & passam a ser conteúdo, sem você pensar neles.

Há uma pegadinha que pega até quem já usa DOM: createElement('nome', $valor) não escapa o &. Este guia mostra o jeito seguro, a validação contra esquema e o que guardar quando o receptor reclamar.

Por que concatenar quebra no terceiro cliente

XML tem cinco caracteres com significado especial: <, >, &, aspas duplas e aspas simples (estas duas dentro de atributos). Quando você escreve '<cliente>' . $nome . '</cliente>', o conteúdo de $nome vira marcação. Um & quebra o documento; um < abre uma tag que não existe. No primeiro cliente de teste, ninguém tem esses caracteres no nome. No terceiro cliente real, alguém tem.

Há outros dois problemas que a concatenação esconde. O primeiro é a codificação: se o texto vem do banco em Latin-1 e o prólogo diz UTF-8, o receptor lê lixo onde havia acento. O segundo são caracteres de controle, como os que chegam colados de planilhas, que o XML 1.0 simplesmente não permite. Uma biblioteca de XML não resolve tudo isso sozinha, mas pelo menos falha de forma explícita em vez de gerar um arquivo que só quebra do outro lado.

Se o seu caso é a nota fiscal eletrônica, o raciocínio é o mesmo, com o acréscimo da assinatura e do leiaute oficial; o post XML da nota não se monta no olho trata desse cenário.

Como montar o XML com DOMDocument, passo a passo

  1. Crie o documento declarando versão e codificação: new DOMDocument('1.0', 'UTF-8').
  2. Garanta que todo texto de entrada está em UTF-8 e sem caracteres de controle proibidos.
  3. Crie cada elemento sem valor e adicione o texto com createTextNode, que escapa os caracteres especiais.
  4. Use setAttribute para atributos; ele também escapa.
  5. Formate números e datas explicitamente: ponto decimal e data em ISO 8601, sem depender do locale do servidor.
  6. Valide contra o XSD, se o receptor fornecer um, antes de enviar.
  7. Grave o XML que saiu, exatamente como foi enviado.

A armadilha do createElement com valor

O segundo parâmetro de DOMDocument::createElement parece prático, mas o texto passado ali não é escapado como conteúdo: um & é interpretado como início de entidade. O PHP emite um aviso de referência de entidade não terminada e o texto não sai como você escreveu. Com createTextNode (ou atribuindo a propriedade textContent), o problema desaparece. O mesmo cuidado vale para addChild do SimpleXML.

$doc = new DOMDocument('1.0', 'UTF-8');

// Errado: o & é lido como início de entidade.
$ruim = $doc->createElement('cliente', 'Silva & Filhos Ltda');

// Certo: o texto entra como conteúdo e é escapado na saída.
$bom = $doc->createElement('cliente');
$bom->appendChild($doc->createTextNode('Silva & Filhos Ltda'));

Um exemplo completo: exportação de pedido

Uma função auxiliar curta evita repetir as três linhas para cada elemento e torna o escape automático em todo o código:

function limparTexto(string $texto): string
{
    if (!mb_check_encoding($texto, 'UTF-8')) {
        throw new InvalidArgumentException('Texto fora de UTF-8');
    }
    return preg_replace('/[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]/u', '', $texto);
}

function el(DOMDocument $doc, DOMNode $pai, string $nome, ?string $texto = null): DOMElement
{
    $elemento = $doc->createElement($nome);
    if ($texto !== null) {
        $elemento->appendChild($doc->createTextNode(limparTexto($texto)));
    }
    $pai->appendChild($elemento);
    return $elemento;
}

$pedido = [
    'numero' => 10234,
    'emitido_em' => new DateTimeImmutable('2026-03-04 09:05:00', new DateTimeZone('America/Sao_Paulo')),
    'cliente' => 'Silva & Filhos Ltda',
    'itens' => [
        ['sku' => 'CAF-250', 'descricao' => 'Café "Serra" 250 g', 'qtd' => 2, 'preco' => 38.9],
        ['sku' => 'FIL-102', 'descricao' => 'Filtro de papel nº 102', 'qtd' => 1, 'preco' => 9.5],
    ],
];

$doc = new DOMDocument('1.0', 'UTF-8');
$doc->formatOutput = true;

$raiz = el($doc, $doc, 'pedido');
$raiz->setAttribute('numero', (string) $pedido['numero']);
el($doc, $raiz, 'emitidoEm', $pedido['emitido_em']->format(DATE_ATOM));
el($doc, $raiz, 'cliente', $pedido['cliente']);

$itens = el($doc, $raiz, 'itens');
foreach ($pedido['itens'] as $item) {
    $no = el($doc, $itens, 'item');
    $no->setAttribute('sku', $item['sku']);
    el($doc, $no, 'descricao', $item['descricao']);
    el($doc, $no, 'quantidade', (string) $item['qtd']);
    el($doc, $no, 'preco', number_format($item['preco'], 2, '.', ''));
}

file_put_contents(__DIR__ . '/saida/pedido-10234.xml', $doc->saveXML());

A saída mostra o escape feito pela biblioteca, sem nenhuma linha sua tratando caractere:

<?xml version="1.0" encoding="UTF-8"?>
<pedido numero="10234">
  <emitidoEm>2026-03-04T09:05:00-03:00</emitidoEm>
  <cliente>Silva &amp; Filhos Ltda</cliente>
  <itens>
    <item sku="CAF-250">
      <descricao>Café "Serra" 250 g</descricao>
      <quantidade>2</quantidade>
      <preco>38.90</preco>
    </item>
    <item sku="FIL-102">
      <descricao>Filtro de papel nº 102</descricao>
      <quantidade>1</quantidade>
      <preco>9.50</preco>
    </item>
  </itens>
</pedido>

Repare que o acento e o "nº" saem como caracteres normais, porque o documento é UTF-8. Não existe motivo para converter letras em entidades; o post sobre acentos no HTML explica a mesma lógica do lado da página.

A função limparTexto remove o que o XML 1.0 não aceita: qualquer caractere fora de tabulação, quebras de linha e dos intervalos válidos do Unicode. E recusa texto que não está em UTF-8, em vez de deixá-lo passar corrompido. Se essa exceção dispara, o problema está antes, na conexão com o banco ou na importação do arquivo; o guia charset UTF-8 de ponta a ponta ajuda a achar a camada.

Arquivos grandes: XMLWriter em vez de DOM

O DOMDocument mantém o documento inteiro na memória. Para exportar 200 mil pedidos, use XMLWriter, que escreve em fluxo direto no arquivo. O writeElement e o writeAttribute também escapam o conteúdo:

$w = new XMLWriter();
$w->openUri(__DIR__ . '/saida/pedidos.xml');
$w->startDocument('1.0', 'UTF-8');
$w->setIndent(true);
$w->startElement('pedidos');
foreach ($repositorio->pedidosDoMes('2026-03') as $p) {
    $w->startElement('pedido');
    $w->writeAttribute('numero', (string) $p['numero']);
    $w->writeElement('cliente', limparTexto($p['cliente']));
    $w->endElement();
}
$w->endElement();
$w->endDocument();
$w->flush();

Aqui $repositorio->pedidosDoMes representa a sua consulta, de preferência um gerador que busca em lotes para não carregar tudo de uma vez.

Validar contra o XSD e guardar o que saiu

Se o receptor publica um esquema XSD, valide antes de enviar. O schemaValidate devolve falso e os detalhes ficam na fila de erros do libxml:

libxml_use_internal_errors(true);
if (!$doc->schemaValidate(__DIR__ . '/xsd/pedido.xsd')) {
    foreach (libxml_get_errors() as $erro) {
        error_log(sprintf('XSD linha %d: %s', $erro->line, trim($erro->message)));
    }
    libxml_clear_errors();
    throw new RuntimeException('XML do pedido 10234 não passou no esquema');
}

Depois do envio, guarde o arquivo exato que saiu, com data e identificador. Quando o parceiro rejeitar, você compara o arquivo, não a sua memória do que o código deveria gerar. E, se um dia o seu sistema também precisar ler XML de terceiros, não habilite a expansão de entidades externas (a opção LIBXML_NOENT, por exemplo): ela abre espaço para ataques que leem arquivos do servidor.

Erros comuns

  • Concatenar strings para montar tags. O primeiro & ou < em dado real quebra o documento inteiro.
  • Passar o valor no segundo parâmetro de createElement. O & não é escapado e o conteúdo sai diferente do esperado, com um aviso no log.
  • Escapar na mão com htmlspecialchars e depois usar DOM. O texto é escapado duas vezes e o receptor recebe &amp;amp;.
  • Formatar decimal com number_format padrão ou locale brasileiro. Sai vírgula ou separador de milhar, e o esquema recusa o número.
  • Ignorar a codificação do texto de entrada. O arquivo diz UTF-8, o conteúdo é Latin-1, e o acento vira lixo do outro lado.
  • Não guardar o XML enviado. Na rejeição, ninguém consegue reproduzir o arquivo que de fato saiu.

Checklist

  • Nenhuma tag é montada por concatenação de string.
  • Todo texto entra por createTextNode, textContent, setAttribute ou métodos de XMLWriter.
  • O documento declara UTF-8 e a entrada é verificada com mb_check_encoding.
  • Caracteres de controle proibidos são removidos antes de entrar no documento.
  • Números usam ponto decimal sem separador de milhar; datas usam ISO 8601.
  • O XML passa pelo XSD do receptor antes do envio.
  • Cada arquivo enviado fica guardado com data e identificador.

Pegue o último XML que o seu sistema enviou e foi rejeitado e procure um & cru ou uma vírgula decimal. Se encontrar, qual é a função que montou aquele trecho, e ela ainda junta texto com ponto?

Avalie este artigo

Referência: http://blog.clares.com.br/gerando-xml-com-php/