Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/josemmo/Verifactu-PHP/llms.txt

Use this file to discover all available pages before exploring further.

Record chaining is the mechanism that gives VERI*FACTU its tamper-evident properties. Each billing record embeds a SHA-256 hash of the record that came before it, creating a linked chain where altering any historical record immediately invalidates all subsequent hashes. This design makes it computationally infeasible to retroactively modify, insert, or delete a record without the change being detectable — whether by the AEAT’s own systems or during a tax audit. Verifactu-PHP implements the full chaining protocol as defined in RD 1007/2023, including the exact field ordering and encoding required by the AEAT spec.

How the chain works

Every record — whether a RegistrationRecord or a CancellationRecord — carries four chaining-related fields inherited from the Record base class:
PropertyTypeDescription
previousInvoiceId?InvoiceIdentifierThe invoice identifier of the immediately preceding record
previousHash?stringFirst 64 uppercase hex characters of the preceding record’s SHA-256 hash
hashstringThis record’s own SHA-256 hash (64 uppercase hex chars)
hashedAtDateTimeImmutableTimestamp (with timezone) at which the hash was computed
The chain rules are:
  • First record in the chain: set previousInvoiceId = null and previousHash = null. The XML serializer writes <sum1:PrimerRegistro>S</sum1:PrimerRegistro> instead of a back-reference.
  • Every subsequent record: set previousInvoiceId to the InvoiceIdentifier of the prior record and previousHash to the prior record’s hash string.
  • Both previousInvoiceId and previousHash must either both be set or both be null — the validatePreviousInvoice callback enforces this.
  • CancellationRecord is stricter: it always requires both previousInvoiceId and previousHash to be set, even if it is the first record the SIF has ever emitted.

Hash calculation for RegistrationRecord

The hash of a RegistrationRecord is the uppercase SHA-256 hex digest of a UTF-8 string built by concatenating the following key-value pairs in exact order, separated by &:
IDEmisorFactura=<issuerId>
&NumSerieFactura=<invoiceNumber>
&FechaExpedicionFactura=<issueDate formatted as DD-MM-YYYY>
&TipoFactura=<invoiceType.value>
&CuotaTotal=<totalTaxAmount>
&ImporteTotal=<totalAmount>
&Huella=<previousHash or empty string if null>
&FechaHoraHusoGenRegistro=<hashedAt formatted as ISO 8601 with offset, e.g. 2025-01-15T10:30:00+01:00>
This is implemented verbatim in RegistrationRecord::calculateHash():
public function calculateHash(): string
{
    // NOTE: Values should NOT be escaped — that is what the AEAT says ¯\_(ツ)_/¯
    $payload  = 'IDEmisorFactura='             . $this->invoiceId->issuerId;
    $payload .= '&NumSerieFactura='            . $this->invoiceId->invoiceNumber;
    $payload .= '&FechaExpedicionFactura='     . $this->invoiceId->issueDate->format('d-m-Y');
    $payload .= '&TipoFactura='                . $this->invoiceType->value;
    $payload .= '&CuotaTotal='                 . $this->totalTaxAmount;
    $payload .= '&ImporteTotal='               . $this->totalAmount;
    $payload .= '&Huella='                     . ($this->previousHash ?? '');
    $payload .= '&FechaHoraHusoGenRegistro='   . $this->hashedAt->format('c');
    return strtoupper(hash('sha256', $payload));
}
The output is always 64 uppercase hexadecimal characters.

Hash calculation for CancellationRecord

A CancellationRecord uses a shorter payload that references the invoice being cancelled rather than general invoice metadata:
IDEmisorFacturaAnulada=<issuerId>
&NumSerieFacturaAnulada=<invoiceNumber>
&FechaExpedicionFacturaAnulada=<issueDate formatted as DD-MM-YYYY>
&Huella=<previousHash or empty string if null>
&FechaHoraHusoGenRegistro=<hashedAt formatted as ISO 8601 with offset>
This is implemented in CancellationRecord::calculateHash():
public function calculateHash(): string
{
    // NOTE: Values should NOT be escaped — that is what the AEAT says ¯\_(ツ)_/¯
    $payload  = 'IDEmisorFacturaAnulada='          . $this->invoiceId->issuerId;
    $payload .= '&NumSerieFacturaAnulada='          . $this->invoiceId->invoiceNumber;
    $payload .= '&FechaExpedicionFacturaAnulada='   . $this->invoiceId->issueDate->format('d-m-Y');
    $payload .= '&Huella='                          . ($this->previousHash ?? '');
    $payload .= '&FechaHoraHusoGenRegistro='        . $this->hashedAt->format('c');
    return strtoupper(hash('sha256', $payload));
}

Using calculateHash()

Once all fields that participate in the hash payload are set, call calculateHash() and assign the result to $record->hash. Always set hashedAt immediately before computing the hash — it is part of the payload, so changing it afterwards will invalidate the hash.
use DateTimeImmutable;
use josemmo\Verifactu\Models\Records\RegistrationRecord;
use josemmo\Verifactu\Models\Records\InvoiceIdentifier;
use josemmo\Verifactu\Models\Records\InvoiceType;
use josemmo\Verifactu\Models\Records\BreakdownDetails;
use josemmo\Verifactu\Models\Records\OperationType;
use josemmo\Verifactu\Models\Records\RegimeType;
use josemmo\Verifactu\Models\Records\TaxType;

$record = new RegistrationRecord();
$record->invoiceId            = new InvoiceIdentifier('A00000000', 'FAC-2025-001', new DateTimeImmutable('2025-01-15'));
$record->issuerName           = 'Acme Supplies, S.L.';
$record->invoiceType          = InvoiceType::Simplificada;
$record->description          = 'Sale of goods';

$details = new BreakdownDetails();
$details->taxType             = TaxType::IVA;
$details->regimeType          = RegimeType::C01;
$details->operationType       = OperationType::Subject;
$details->baseAmount          = '100.00';
$details->taxRate             = '21.00';
$details->taxAmount           = '21.00';
$record->breakdown[]          = $details;

$record->totalTaxAmount       = '21.00';
$record->totalAmount          = '121.00';

// Set the chain back-reference first, then stamp the time and compute the hash
$record->previousInvoiceId    = null;  // first record in the chain
$record->previousHash         = null;

$record->hashedAt             = new DateTimeImmutable(); // current date/time
$record->hash                 = $record->calculateHash();
Do not modify any field that participates in the hash payload after calling calculateHash(). The affected fields for RegistrationRecord are: invoiceId (any sub-field), invoiceType, totalTaxAmount, totalAmount, previousHash, and hashedAt. Changing any of these after the hash is stored will cause the validateHash callback to fail during validate() — and the AEAT will reject the record.

Chain integrity validation

validate() automatically verifies that the stored hash matches the record’s current state via the validateHash callback defined in the Record base class:
#[Assert\Callback]
final public function validateHash(ExecutionContextInterface $context): void
{
    $expectedHash = $this->calculateHash();
    if ($this->hash !== $expectedHash) {
        $context->buildViolation("Invalid hash, expected value $expectedHash")
            ->atPath('hash')
            ->addViolation();
    }
}
If any hashed field has been changed after calculateHash() was called, the violation message will include the expected hash value, making it straightforward to diagnose which record is out of sync.

First record in a chain

When the SIF has no prior records — either because it is issuing its very first billing record ever, or because the chain has been officially reset — set both chain-back-reference properties to null:
use DateTimeImmutable;
use josemmo\Verifactu\Models\Records\RegistrationRecord;

$record = new RegistrationRecord();
// ... set invoiceId, issuerName, invoiceType, description, breakdown, totals ...

// No previous record
$record->previousInvoiceId = null;
$record->previousHash      = null;

$record->hashedAt = new DateTimeImmutable();
$record->hash     = $record->calculateHash();
When exported to XML, the library writes <sum1:PrimerRegistro>S</sum1:PrimerRegistro> in the <sum1:Encadenamiento> block instead of a <sum1:RegistroAnterior> element.

Subsequent records

For every record after the first, supply the InvoiceIdentifier of the preceding record and its hash string:
use DateTimeImmutable;
use josemmo\Verifactu\Models\Records\InvoiceIdentifier;
use josemmo\Verifactu\Models\Records\RegistrationRecord;

// Assume $previousRecord is the already-hashed preceding RegistrationRecord
$record = new RegistrationRecord();
// ... set invoiceId, issuerName, invoiceType, description, breakdown, totals ...

// Link back to the previous record
$record->previousInvoiceId = $previousRecord->invoiceId;             // InvoiceIdentifier object
$record->previousHash      = $previousRecord->hash;                  // 64-char uppercase hex string

$record->hashedAt = new DateTimeImmutable();
$record->hash     = $record->calculateHash();
$record->validate();
The hashedAt timestamp is serialised using DateTimeInterface::ISO8601 format (e.g. 2025-01-15T10:30:00+01:00). PHP’s DateTimeImmutable uses your system timezone by default. Always construct hashedAt with an explicit timezone — for example new DateTimeImmutable('now', new DateTimeZone('Europe/Madrid')) — to ensure the offset in the hash payload and in the XML output is correct. A mismatch between the timezone used during calculateHash() and the one stored in hashedAt will cause the hash to be permanently wrong.

Build docs developers (and LLMs) love