This repo holds the core and every authority and key-source package. Each folder in packages/ is split into its own read-only repo by .github/workflows/split.yml and published on Packagist separately.
One PHP interface for tax-authority e-invoicing: sign every invoice and submit it. Install the core, then only the authority you need.
composer require hamada-emam-tech/einvoice # the core: model, software keys, a fake authority for tests
composer require hamada-emam-tech/einvoice-zatca # Saudi ZATCA (Fatoora)
composer require hamada-emam-tech/einvoice-eta # Egypt ETA
composer require hamada-emam-tech/einvoice-keysource-agent # USB token or HSM, via signing-agentInstalling an authority package is enough. The core finds it automatically.
Status: the core ships
SoftwareKeyand thefakeauthority. ZATCA is available ineinvoice-zatca, tested live against ZATCA's developer portal. Egypt ETA is ineinvoice-eta, checked against ETA's SDK samples and with OpenSSL; its pre-production test is still to run. You can already write your own withextend()(see writing an authority or key source).
The authority decides the document format and the API: UBL XML for ZATCA, canonical JSON for ETA. The key source decides where the private key lives. The authority never knows which one it got.
| Key source | Where the key lives | Package |
|---|---|---|
SoftwareKey |
A PEM file or secret your app holds (ZATCA) | in the core |
| Signing Agent | A USB token or HSM, through PKCS#11 and the local signing-agent (Egypt ETA) | einvoice-keysource-agent |
| CSC | A cloud signing service (CSC API) | einvoice-keysource-csc (planned) |
use HamadaEmamTech\Einvoice\Einvoice;
use HamadaEmamTech\Einvoice\Model\{Address, Invoice, Line, Money, Party};
use HamadaEmamTech\Einvoice\Signing\SoftwareKey;
$einvoice = new Einvoice([
'default' => 'zatca',
'authorities' => [
'zatca' => [
'environment' => 'sandbox',
'certificate' => getenv('ZATCA_CSID_TOKEN'), // from onboarding, see einvoice-zatca
'secret' => getenv('ZATCA_CSID_SECRET'),
// A KeySource, or a callable returning one, so the key is only read when signing.
'key' => fn () => SoftwareKey::fromFile('/secrets/egs.key', getenv('EGS_KEY_PASS'), '/secrets/csid.pem'),
],
],
]);
$invoice = new Invoice(
number: 'INV-1001',
issuedAt: new DateTimeImmutable(),
seller: new Party('Seller Co', '300000000000003',
new Address('SA', 'Riyadh', 'King Fahd Road', '1234', 'Al Olaya', '12345'),
identifiers: ['CRN' => '1010010000']),
buyer: new Party('Buyer Co', '311111111111113',
new Address('SA', 'Riyadh', 'Olaya Street', '2222', 'Al Olaya', '12222')),
lines: [Line::taxed('Widget', '3', Money::of('10.00', 'SAR'), 'S', '15')],
currency: 'SAR',
extra: ['zatca' => ['icv' => 1001, 'previous_hash' => $lastHash]], // authority-specific fields
);
$signed = $einvoice->sign($invoice); // prepare + sign. Store $signed before you submit.
$result = $einvoice->submit($signed); // ideally from a queue, with retriesSigning for many tenants or devices? Pass the key in: $einvoice->sign($invoice, $tenantKey).
Amounts are integer minor units plus a currency. Parse them from strings:
Money::of('115.50', 'SAR'); // 11550 halalas
Money::of('1.234', 'KWD'); // 3-decimal currencies are handled
Money::of('10.005', 'SAR'); // throws: round it yourself, nothing is rounded silently
Money::of('100.00', 'SAR')->percent('15'); // 15.00, half away from zeroA float changes the canonical string, and one changed character breaks the signature.
One submission can come back with accepted and rejected documents together (Egypt ETA does this). A rejected document is data, not an exception:
foreach ($result->rejected as $rejected) {
foreach ($rejected->errors as $problem) {
log("{$rejected->invoiceNumber}: [{$problem->code}] {$problem->message}");
}
}AuthorityError is thrown only when the call itself fails: authentication, an outage, a malformed request.
$key = SoftwareKey::generate('secp256k1'); // ZATCA uses secp256k1
file_put_contents('egs.key', $key->exportPrivateKey($passphrase));
$key = SoftwareKey::fromFile('egs.key', $passphrase)->withCertificate($csidPem);SoftwareKey stays out of var_dump(), dd() and error trackers.
$einvoice = new Einvoice(['default' => 'fake']);
$fake = $einvoice->authority(); // HamadaEmamTech\Einvoice\Testing\FakeAuthority
$fake->reject('INV-2', 'Buyer VAT number is invalid');
$result = $einvoice->issue($invoice);
$fake->assertSubmitted(fn ($doc) => $doc->invoiceNumber === 'INV-1001');FakeKeySource signs anything and remembers it: $key->assertSigned().
| Package | Authority | Key | Status |
|---|---|---|---|
hamada-emam-tech/einvoice-zatca |
ZATCA, Saudi Arabia (Fatoora) | software key, secp256k1 | available |
hamada-emam-tech/einvoice-eta |
ETA, Egypt | RSA certificate (USB token, HSM or PEM), CAdES-BES | built; pre-production test pending |
Framework-agnostic: PHP 8.2+ with ext-openssl, no framework dependency.