CFDI 4.0 en PHP: Los 5 errores que se repiten en cada proyecto
XSLT offline mal configurado, confusión DER/PEM, c_ClaveProdServ incorrecta, PAC sin reintentos, cadena original errónea. Código real mostrando el error y la corrección.
Llevo más de dos décadas construyendo aplicaciones PHP que interactúan con el SAT. En ese tiempo he visto los mismos cinco errores en casi cada integración de CFDI que me han pedido revisar o rescatar. Algunos los cometí yo mismo. Te los explico para que no tengas que aprenderlos de la manera difícil.
Error #1: XSLT para cadena original cargado desde el servidor del SAT
El error más frecuente que veo. Para construir la cadena original del CFDI necesitas aplicar una transformación XSLT al XML. El SAT publica esos archivos XSLT en su sitio. Muchos developers los cargan directamente desde ahí.
// Funciona en tu máquina. Falla en producción exactamente cuando no puedes permitirte que falle.
$xslt = new XSLTProcessor();
$doc = new DOMDocument();
$doc->load('http://www.sat.gob.mx/sitio_internet/cfd/4/cadenaoriginal_4_0.xslt');
$xslt->importStyleSheet($doc);
El servidor del SAT tiene tiempos de respuesta variables y cae en mantenimiento. Cuando eso pasa, tu aplicación no puede generar la cadena original y el timbrado falla. El error llega en producción, a las 11pm, cuando tu cliente más grande está facturando cierre de mes.
// Descarga el XSLT una vez, lo commiteas con tu proyecto, lo lees desde disco.
$xslt = new XSLTProcessor();
$doc = new DOMDocument();
$doc->load(__DIR__ . '/../resources/xslt/cadenaoriginal_4_0.xslt');
$xslt->importStyleSheet($doc);
// Con ForgeStack: los XSLTs van empaquetados, no tienes que gestionar esto.
$cadena = CadenaOriginal::fromXml($xml); // local, offline, 0ms extra
Error #2: Confusión entre el formato DER y PEM de la llave privada
El archivo .key que el SAT genera para tu CSD está en formato DER (binario). La mayoría de librerías PHP de criptografía — y openssl_* en particular — esperan formato PEM (base64 con cabeceras). Si alimentas directamente el .key a openssl_pkey_get_private, el resultado es un error críptico que no te explica nada sobre el formato.
$keyDer = file_get_contents('/ruta/a/tu.key');
// ⛔ openssl_pkey_get_private espera PEM, no DER — devuelve false sin mensaje claro
$keyRes = openssl_pkey_get_private($keyDer, $password);
$sello = openssl_sign($cadena, $sig, $keyRes, OPENSSL_ALGO_SHA256);
$keyDer = file_get_contents('/ruta/a/tu.key');
// Descifra DER con la contraseña del CSD (algoritmo 3DES que usa el SAT)
$keyDec = openssl_pkcs12_read(...) // o el equivalente para PKCS8
// Más limpio: usa una librería que entiende el formato del SAT de entrada.
// Con ForgeStack:
$csd = Csd::fromFiles(
certPath: '/ruta/a/tu.cer',
keyPath: '/ruta/a/tu.key', // DER aceptado directamente
password: $tuPassword
);
$sello = $csd->sign($cadena); // Base64, listo para el XML
Error #3: c_ClaveProdServ incorrecta o genérica
El SAT mantiene un catálogo de más de 50,000 claves de productos y servicios (c_ClaveProdServ). Cada artículo de tu factura necesita la clave correcta. Lo que veo frecuentemente es usar 01010101 — la clave genérica "no identificado" — para todo. El PAC la acepta porque es válida. El problema viene cuando el SAT audita: facturas con todo como "no identificado" son una señal de alerta.
El segundo problema: el catálogo del SAT se actualiza. Si lo descargas una vez y lo hardcodeas, en 6 meses tienes claves obsoletas y el PAC empieza a rechazar facturas.
// ⚠️ '01010101' es "no identificado" — acepta el PAC hoy, bandera roja en auditoría mañana.
$concepto = [
'ClaveProdServ' => '01010101', // ❌ no hacer esto
'Descripcion' => 'Servicios de desarrollo',
'Importe' => '10000.00',
];
// Para "Servicios de desarrollo de software": 43232408
// Busca la tuya en: sat.gob.mx/cs/Satellite?c=Page&pagename=CatalogoCFDI
// Con ForgeStack — catálogo validado en build time:
$item = InvoiceItem::service(
description: 'Desarrollo de software',
quantity: 1,
unitPrice: Money::mxn(10000_00), // en centavos
claveProdServ: '43232408', // validado vs catálogo vigente
claveUnidad: 'E48', // Unidad de Servicio
);
// Lanza InvalidCatalogCodeException si la clave no existe en el catálogo actual.
Error #4: Sin manejo de rechazos del PAC
El PAC puede rechazar tu CFDI por varias razones: contenido inválido (error tuyo, no recuperable), UUID duplicado (ya timbrado, idempotente — devuelve el timbrado original), o error transitorio del servicio (reintentable). Si no distingues entre estos casos, o no reintentas los transitorios, acabas con facturas que nunca se timbran y tu cliente llama enojado.
$response = $pac->timbrar($cfdiXml);
// Si $response->codigo !== '200' simplemente lanzamos una excepción genérica.
// No sabemos si fue transitorio (reintentable) o fatal (no reintentable).
// No guardamos el estado. No alertamos. La factura queda en el limbo.
// Los errores del PAC tienen categorías con semántica distinta:
// - 307: UUID duplicado → idempotente, devuelve el XML ya timbrado
// - 301: XML inválido → error tuyo, no reintentes — corrígelo
// - 5xx: servicio caído → reintentable con backoff exponencial
// Con ForgeStack — la clasificación y los reintentos están incluidos:
try {
$timbrado = $cfdi->stamp($invoice); // reintentos automáticos si es transitorio
} catch (DuplicateUuidException $e) {
$timbrado = $e->getExistingStamp(); // ya estaba timbrado, usa ese
} catch (PacValidationException $e) {
// Error en tu XML — no reintentes, corrígelo
logger()->error('pac.validation', ['code' => $e->getPacCode(), 'detail' => $e->getMessage()]);
}
Error #5: Cadena original construida manualmente
La cadena original de un CFDI no es simplemente concatenar los atributos del XML. Es el resultado de aplicar una transformación XSLT específica que el SAT publica, que maneja orden de atributos, nodos opcionales, y escape de caracteres de una manera muy precisa. Si la construyes manualmente — concatenando strings con pipes, iterando atributos a mano — tarde o temprano vas a tener una cadena que no coincide con lo que el PAC calcula, y el sello no va a verificar.
// Parece razonable hasta que el SAT cambia el orden de atributos en el catálogo,
// o un nodo complemento tiene espacios de nombre adicionales.
$cadena = '||' . $version . '|';
$cadena .= $serie . '|';
$cadena .= $folio . '|';
// ... 40 campos más, todos a mano. Cualquier nodo opcional rompe la concatenación.
// La cadena original SE CALCULA con la XSLT oficial del SAT. No hay otro camino.
// La transformación maneja orden, opcionalidad, y complementos automáticamente.
$xslt = new XSLTProcessor();
$xslt->importStyleSheet(local_xslt_document()); // ← local, no desde internet
$cadena = $xslt->transformToXml($xmlDoc);
// ForgeStack: esto está resuelto en CadenaOriginal::fromXml()
// No tienes que pensarlo.
En resumen
Los cinco errores tienen algo en común: son consecuencias de construir CFDI desde cero sin suficiente documentación sobre las peculiaridades del SAT. El SAT no es Stripe — no tiene una API diseñada para developers, no tiene SDKs oficiales bien mantenidos, y la documentación oficial asume que ya conoces el contexto del sistema fiscal mexicano.
El antídoto no es leer más documentación del SAT. Es usar una implementación que ya tiene estos casos resueltos, probados, y mantenidos. Que cuando el catálogo se actualiza, se actualiza solo. Que cuando el PAC cambia una respuesta, el manejo de errores ya estaba previsto.
¿Conciliar pagos SPEI a mano cada mes?
Estamos construyendo una herramienta para automatizar la conciliación de pagos SPEI en despachos y negocios mexicanos. Antes de construirla, queremos entender bien el problema — 4 preguntas, 2 minutos.
Sin compromiso, sin dejar tus datos.
Responder la encuesta