christianoerick / nfse-nacional
NFS-e Nacional (Sefin Nacional / ADN) em PHP puro, sem framework: DPS completa nos leiautes oficiais (1.01 e NT 009), assinatura XMLDSig com certificado A1, emissão, consulta, eventos (cancelamento, substituição, análise fiscal), parâmetros municipais, simulação sem rede e DANFSe.
Requires
- php: ^8.2
- ext-curl: *
- ext-dom: *
- ext-libxml: *
- ext-mbstring: *
- ext-openssl: *
- ext-zlib: *
Requires (Dev)
- chillerlan/php-qrcode: ^5.0 || ^6.0
- dompdf/dompdf: ^3.0
- phpunit/phpunit: ^11.5 || ^12.0
Suggests
- chillerlan/php-qrcode: QR Code no DANFSe gerado localmente.
- dompdf/dompdf: Gerar o PDF do DANFSe (DanfseRenderer::render). Precisa de ext-gd.
Provides
None
Conflicts
None
Replaces
None
README
Emissão de NFS-e pelo Sistema Nacional (Sefin Nacional / ADN) em PHP puro, sem framework e sem dependências obrigatórias: HTTP pelo cURL nativo, XML e assinatura pelas extensões do PHP.
Usa Laravel? Veja christianoerick/laravel-nfse-nacional, com o mesmo motor mais facade, config, comandos artisan e
Http::fake().
- DPS completa nos leiautes oficiais 1.01 e NT 009 (troca automática em 01/12/2026). Todos os campos do XSD são aceitos pelos nomes oficiais das tags, inclusive IBS/CBS, comércio exterior, obra, evento, deduções e benefícios. A ordem dos campos é tirada do XSD, e a DPS é validada localmente antes do envio.
- Assinatura XMLDSig com certificado A1 (.pfx), em RSA-SHA1 (padrão do manual) ou RSA-SHA256. Abre também .pfx antigo com cifra RC2.
- Emissão segura contra duplicidade.
emitOrRecover()consulta a DPS antes de reenviar. - Consultas e eventos: NFS-e por chave, DPS por Id, cancelamento (e101101), cancelamento por substituição (e105102) e qualquer outro evento do leiaute.
- ADN: DANFSe oficial em PDF, parâmetros municipais (convênio, alíquotas, regimes, retenções, benefícios) e distribuição por NSU.
- DANFSe v2.0 gerado localmente, no mesmo leiaute do Sistema Nacional, com QR Code.
- Enums com rótulo para todas as listas do leiaute, prontos para
<select>. Inclui também a lista nacional de serviços (cTribNac) e os códigos de rejeição do Anexo I. - mTLS com verificação SSL ligada. O certificado vai para arquivos temporários 0600, apagados ao fim.
- Transporte trocável:
CurlTransportpor padrão;FakeTransportpara testes, ou o seu (Guzzle, PSR-18) implementandoTransport.
Instalação
composer require christianoerick/nfse-nacional
# opcionais, só para o DANFSe gerado localmente:
composer require dompdf/dompdf chillerlan/php-qrcode
Requisitos: PHP 8.2+ e as extensões curl, openssl, dom, libxml, mbstring e zlib (gd só para o PDF do DANFSe).
use ChristianoErick\Nfse\NfseNacional; $nfse = new NfseNacional([ 'environment' => 'production', // ou 'restricted' (produção restrita) 'application_version' => 'MeuSistema-1.0', 'signature_algorithm' => 'sha1', // ou 'sha256' ]);
Passe só o que quiser mudar; o resto vem de Config::DEFAULTS (URLs, leiaute, timeouts, retentativas, pasta temporária...). A classe NfseNacional é só um atalho: dá para usar new NfseClient($certificado, 'production', $config) direto.
Certificado
$certificado = $nfse->certificate(file_get_contents($caminhoPfx), $senha); // ou ChristianoErick\Nfse\Certificate\Certificate::fromPfx($pfx, $senha) $certificado->document(); // "11222333000181" $certificado->holderName(); // "EMPRESA LTDA" $certificado->validTo(); // DateTimeImmutable (America/Sao_Paulo) $certificado->daysUntilExpiration(); // 351 $certificado->toPfx($senha); // regrava em AES-256 (útil para .pfx com cifra antiga)
Emitir
use ChristianoErick\Nfse\Dps\Dps; use ChristianoErick\Nfse\Enums\IssqnWithholding; $dps = Dps::make([ 'serie' => 1, // aplicativo próprio: 1 a 49999 'nDPS' => 15, // sequencial por série; nunca repita 'dCompet' => new DateTimeImmutable('today'), 'cLocEmi' => '2704302', // IBGE do município emissor 'prest' => [ 'CNPJ' => '11222333000181', 'fone' => '82999990000', 'email' => 'financeiro@empresa.com.br', 'regTrib' => ['opSimpNac' => 3, 'regApTribSN' => 1, 'regEspTrib' => 0], ], 'toma' => [ 'CNPJ' => '11444777000161', 'xNome' => 'CLIENTE LTDA', 'end' => [ 'endNac' => ['cMun' => '2704302', 'CEP' => '57000000'], 'xLgr' => 'Rua Exemplo', 'nro' => '100', 'xBairro' => 'Centro', ], ], 'serv' => [ 'locPrest' => ['cLocPrestacao' => '2704302'], 'cServ' => ['cTribNac' => '010501', 'cTribMun' => '001', 'xDescServ' => 'Licenciamento de software.'], ], 'valores' => [ 'vServPrest' => ['vServ' => 7500], 'trib' => [ 'tribMun' => ['tribISSQN' => 1, 'tpRetISSQN' => IssqnWithholding::NotWithheld], 'tribFed' => ['piscofins' => ['CST' => '00', 'tpRetPisCofins' => 0]], 'totTrib' => ['pTotTribSN' => 6.00], ], ], ]); $client = $nfse->client($certificado); $resultado = $client->emitOrRecover($dps); $resultado->accessKey(); // chave de 50 dígitos $resultado->number(); // número da NFS-e $resultado->xml(); // XML da NFS-e (guarde!) $resultado->nfse->summary(); // dados prontos para gravar
O pacote preenche sozinho tpAmb, verAplic, tpEmit = 1 e dhEmi (um minuto antes de agora, porque o Sefin recusa dhEmi no futuro). Também calcula o Id da DPS. Valores aceitam número, string, enum, DateTimeInterface e bool.
Todos os campos possíveis estão em docs/CAMPOS.md, gerado do XSD oficial, com ocorrência, formato, opções e descrição.
Erros
| Exceção | Quando | O que fazer |
|---|---|---|
InvalidDataException |
campo que não existe no leiaute (a mensagem lista os aceitos) | corrigir o array |
SchemaValidationException |
a DPS não passa no XSD; nada foi enviado | $e->errors |
RejectedException |
o Sefin recusou | $e->codes() (ex.: ['E0014']) e $e->messages |
ConnectionException |
rede, timeout, 429 ou 5xx | tentar de novo com emitOrRecover() |
NotFoundException |
consulta sem resultado | — |
ChristianoErick\Nfse\Codes\ErrorCodes::message('E0014') devolve a mensagem oficial do código.
Duplicar uma nota
$original = $client->get($chave); // ou NfseDocument::fromXml($xmlGuardado) $nova = $original->dps() ->duplicate(series: 1, number: $proximoNumero) // tira dhEmi/verAplic/tpAmb/subst ->with('dCompet', new DateTimeImmutable('today')) ->with('valores.vServPrest.vServ', 8200) ->with('toma.CNPJ', '11444777000161'); $client->emitOrRecover($nova);
Funciona também com nota emitida no portal. A DPS dela usa a série 70000 (Emissor Nacional web), e duplicate() troca pela série do seu aplicativo.
Cancelar e substituir
use ChristianoErick\Nfse\Enums\AnalysisCancellationReason; use ChristianoErick\Nfse\Enums\CancellationReason; use ChristianoErick\Nfse\Enums\ReplacementReason; use ChristianoErick\Nfse\Events\AuthorEvents; use ChristianoErick\Nfse\Events\EventRequest; $client->cancel($chave, CancellationReason::IssueError, 'Valor informado errado na emissão.'); // Substituição: a nova DPS leva o grupo subst. O Sefin gera a nova e cancela a original. $client->emitOrRecover($nova->with('subst', [ 'chSubstda' => $chaveOriginal, 'cMotivo' => ReplacementReason::Other, 'xMotivo' => 'Correção do valor do serviço.', ])); // Município que limita o cancelamento direto (E0823): pedido de análise fiscal (e101103). $client->registerEvent(EventRequest::analysisCancellation($chave, AnalysisCancellationReason::IssueError, 'Nota emitida com valor errado.')); // Catálogo para a tela "Registrar evento" (evento, motivos, justificativa): AuthorEvents::all('provider'); // e101101, e101103, e202201, e202205 $client->registerEvent(EventRequest::fromCode($chave, 'e202205', '9', 'Justificativa...')); // Qualquer outro evento, pelos nomes oficiais: $client->registerEvent(EventRequest::make($chave, 'e202201', [...]));
Consultas
$client->dpsExists($dpsId); // HEAD /dps/{id} $client->accessKeyForDps($dpsId); // GET /dps/{id} $client->find($chave); // NfseDocument|null $client->event($chave, '101101'); // evento por tipo/sequência $client->events($chave); // todos os eventos (ADN) $client->distribution($nsu); // documentos por NSU (ADN)
Parâmetros municipais
$client->municipalAgreement('2704302')->usesNationalIssuer(); // dá para emitir pelo Sefin? $client->municipalRate('2704302', '010501', '2026-11-01', municipalCode: '001'); // consulta 01.05.01.001 $client->specialRegimes('2704302', '01.05.01.001', '2026-11-01'); $client->withholdings('2704302', '2026-11-01');
DANFSe
$pdf = $client->danfse($chave); // PDF oficial (ADN) $pdf = (new DanfseRenderer) // gerado localmente, mesmo leiaute ->resolveCityUsing(fn (string $ibge) => Municipio::nome($ibge)) // opcional ->watermark($cancelada ? 'CANCELADA' : null) ->render(NfseDocument::fromXml($xml)); // precisa de dompdf/dompdf + gd $html = (new DanfseRenderer)->html($nfseDocument); // só o HTML, sem dependências
Para personalizar, copie resources/templates/danfse.php e use ->template('/caminho/danfse.php'). O template recebe $d (dados já formatados), $e (escape) e $limit.
Listas para formulários
use ChristianoErick\Nfse\Codes\ServiceCodes; use ChristianoErick\Nfse\Enums\SimplesNacionalOption; SimplesNacionalOption::options(); // [['value' => '1', 'label' => 'Não Optante'], ...] (new ServiceCodes)->search('software'); (new ServiceCodes)->options();
São 39 enums, um para cada lista fechada do leiaute: regimes, tributação e retenção do ISSQN, CST de PIS/COFINS, imunidades, motivos de cancelamento e substituição, comércio exterior, IBS/CBS, UF e outras.
Ambiente de testes
Use 'environment' => 'restricted' na configuração, ou passe o ambiente por chamada: $nfse->client($certificado, 'restricted').
Testar sem produção
Validar sem emitir. Monta a DPS e confere tudo o que dá para conferir sem gerar nota:
- No local: certificado, XSD do leiaute em uso e assinatura.
- No Sistema Nacional, só leitura: convênio do município, se o código de serviço está ativo no município (o que pega o E0312) e se a DPS já foi usada.
foreach ($client->check($dps) as $item) { // ['check' => 'serviço', 'ok' => false, 'message' => 'Código 01.05.01.001 não está administrado pelo município ...'] }
Simulação. O SimulatedNfseClient monta, valida e assina a DPS de verdade, mas não manda nada para o Sistema Nacional:
- devolve uma NFS-e gerada localmente, válida no XSD e assinada com o seu certificado;
- a nota é marcada no
xOutInfcomo SIMULAÇÃO; - aceita eventos.
Serve para testar o fluxo inteiro de um sistema sem tocar em produção. Também resolve o caso de municípios sem convênio ativo na produção restrita, como Maceió, onde a restrita recusa com E0037 ou E0084.
$client = $nfse->client($certificado, 'restricted', simulate: true); // ou new NfseNacional(['simulate' => true]) para todos os clientes
Testes automatizados do seu sistema. Troque o transporte por um FakeTransport, que responde por padrão de URL e guarda o que foi enviado. Requisição sem rota falha, então nada chega ao Sistema Nacional:
use ChristianoErick\Nfse\Http\Transport\FakeTransport; use ChristianoErick\Nfse\Http\Transport\HttpRequest; $fake = new FakeTransport([ '*/nfse' => FakeTransport::response(['nfseXmlGZipB64' => NfseClient::pack($xmlNfse)], 201), '*/dps/*' => FakeTransport::response([], 404), ]); $client = (new NfseNacional([], $fake))->client($certificado, 'restricted'); $client->emit($dps); $fake->wasSent(fn (HttpRequest $r) => $r->method === 'POST'); // true
Transporte HTTP
O padrão é o CurlTransport (extensão curl, mTLS com o PEM temporário do certificado). Para usar outro cliente, implemente ChristianoErick\Nfse\Http\Transport\Transport:
final class GuzzleTransport implements Transport { public function send(HttpRequest $request): HttpResponse { // $request->method, url, headers, body, certificatePath, keyPath, options['timeout'|'connect_timeout'|'verify'] // devolva new HttpResponse($status, $body, $headers) para qualquer status; // lance ConnectionException só quando não houver resposta. } } $nfse = new NfseNacional($config, new GuzzleTransport);
Leiautes (NT 009)
O pacote traz os dois pacotes oficiais de XSD e escolhe sozinho qual usar, pela data e pelo ambiente:
| Leiaute | Esquemas oficiais | Produção restrita | Produção |
|---|---|---|---|
v1.01 |
09/02/2026 | até 19/10/2026 | até 30/11/2026 |
nt009 (NT SE/CGNFS-e 009 v1.01) |
20/10/2026 | desde 20/10/2026 | a partir de 01/12/2026 |
new NfseNacional([ 'layout' => 'auto', // ou 'v1.01' / 'nt009' para fixar 'layout_switch' => ['production' => '2026-12-01', 'restricted' => '2026-10-20'], // se o governo mudar a data ]);
O mesmo array de DPS serve nos dois leiautes. O pacote coloca finNFSe no lugar certo (no infDPS na NT 009, com padrão 0 = regular; em IBSCBS no 1.01) e move CST/cClassTrib do IBS/CBS entre trib e trib/gIBSCBS. Grupos que não têm equivalente direto geram erro explicando o que usar: vDedRed (1.01) × vAjusteBC (NT 009), gReeRepRes e bensMoveis.
$client->layout() diz qual leiaute aquele cliente usa. Campos de cada um: docs/CAMPOS.md (NT 009) e docs/CAMPOS-v1.01.md.
Sobre os XSD
Os esquemas oficiais ficam em resources/schemas/{v1.01,nt009}, baixados do portal gov.br/nfse. Há um ajuste só no v1.01: o padrão de TSSerieDPS vem como ^0{0,4}\d{1,5}$, mas em XSD ^ e $ são literais, então o libxml recusaria qualquer série; na nossa cópia é 0{0,4}\d{1,5}. O da NT 009 já veio corrigido.
Quando sair um XSD novo, coloque-o numa pasta em resources/schemas, registre em tools/generate.php e em Support\Layout, e rode composer generate. Isso regera os mapas, os enums e a documentação dos campos.
Testes
composer test
Licença
MIT.