Search by

beziworld / laravel-jpk

kamcw

Polish JPK (SAF-T) for Laravel: JPK_V7M(3)/JPK_V7K(3), JPK_FA(4) and JPK_MAG builders validated against bundled official XSD, plus encrypted submission to the e-Dokumenty gateway with UPO polling.

Package info

github.com/Beziworld/laravel-jpk

Homepage

Issues

pkg:composer/beziworld/laravel-jpk

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

v1.0.0 2026-11-04 18:38 UTC

This package is auto-updated.

Last update: 2026-11-04 20:12:50 UTC


README

tests Latest Version License

Polish JPK (Jednolity Plik Kontrolny, SAF-T) for Laravel:

  • JPK_V7M(3) and JPK_V7K(3), the variants in force since 1 February 2026: typed sales and purchase rows, KSeF number or OFF / BFK / DI marker on every row, GTU_01 to GTU_13, procedure markings (TP, MR_UZ, WSTO_EE, ...), RO / WEW / FP and MK / VAT_RR / WEW documents, bad debt relief corrections, control sums and the whole VAT-7 (23) / VAT-7K (17) declaration computed from the rows
  • JPK_FA(4) with per-rate totals, foreign currency PLN tax, corrections, advance invoices and orders
  • JPK_MAG(1) (periods until 31.12.2026) and JPK_MAG(2) (periods from 1.01.2027), chosen automatically, including PW, MMWE / MMWY and stocktaking (INW)
  • the official XSD files bundled with every imported dictionary rewritten to local copies: every generated file is validated offline before it leaves the builder
  • sending to the Ministry of Finance gateway (e-Dokumenty REST API, test and production): ZIP, split, AES-256 encryption, RSA-encrypted key, InitUpload metadata, InitUploadSigned, Put Blob to Azure, FinishUpload, Status polling and UPO
  • signing with an external qualified signature or Trusted Profile (the package hands you the InitUpload XML to sign) or with authorization data (NIP / PESEL, name, birth date, income) for natural persons
  • tax office code dictionary (from the official schema), a validation rule, conversion of KSeF FA(3) invoices into register rows, jpk:validate and jpk:status commands

Polska wersja dokumentacji

Why this package

Existing PHP packages on Packagist (dev-lancer/jpk-generator, sj_royd/xml-jpk-vat, sj_royd/jpk_gate, arteq/jpk-fa, zielony/jpkfa) either generate generic XML without the schemas, target structures that are no longer in force (JPK_VAT(3), 2019) or only send files. None of them is a Laravel package, none supports JPK_V7(3) with the mandatory KSeF fields, and none validates the result against the official XSD. This package covers the whole path from rows to the UPO in one place, with the schemas in the repository and tests that validate every generated file.

Installation

composer require beziworld/laravel-jpk
php artisan vendor:publish --tag=jpk-config   # optional
JPK_ENVIRONMENT=test          # test (test-e-dokumenty.mf.gov.pl) or production (e-dokumenty.mf.gov.pl)
JPK_SYSTEM_NAME="My ERP 2.1"  # NazwaSystemu written to JPK_V7
JPK_CERTIFICATE=              # path of a newer MF public-key certificate; bundled ones valid until 07.2027
JPK_VERIFY_SIGNATURE=false    # test environment: verify the qualified signature like production
JPK_POLL_ATTEMPTS=30
JPK_POLL_INTERVAL_MS=10000

Requires PHP 8.3+, Laravel 12 or 13, ext-dom, ext-openssl and ext-zip.

JPK_V7M / JPK_V7K

use Beziworld\Jpk\Data\Taxpayer;
use Beziworld\Jpk\Enums\RefundTerm;
use Beziworld\Jpk\Facades\Jpk;

$xml = Jpk::v7m(2026, 10)
    ->taxOffice('1471')                                   // KodUrzedu, checked against the official dictionary
    ->taxpayer(Taxpayer::company('5261040828', 'ACME sp. z o.o.', 'biuro@acme.pl'))
    ->addSale([
        'number' => 'FV/1/10/2026',
        'issue_date' => '2026-10-05',
        'name' => 'Klient S.A.',
        'tax_id' => '1234563218',
        'ksef_number' => '5261040828-20261005-0100A0B1C2D3-4F',
        'gtu' => ['GTU_12'],
        'procedures' => ['TP'],
        'rates' => ['23' => ['net' => '1000.00', 'vat' => '230.00'], 'zw' => '50.00'],
    ])
    ->addSale([
        'number' => 'RO/10/2026', 'issue_date' => '2026-10-31',
        'marker' => 'DI', 'type' => 'RO',                 // cash register report, no KSeF number
        'amounts' => ['K_19' => '812.20', 'K_20' => '186.81'],
    ])
    ->addPurchase([
        'number' => 'FZ/77', 'issue_date' => '2026-10-02', 'receipt_date' => '2026-10-04',
        'name' => 'Dostawca sp. j.', 'tax_id' => '9512345675',
        'ksef_number' => '9512345675-20261002-0100A0B1C2D3-4F',
        'net' => '500.00', 'vat' => '115.00',            // K_42 / K_43; 'fixed_assets' => true for K_40 / K_41
    ])
    ->declaration(['previous_excess' => 120])             // P_39; everything else is computed
    ->toXml();                                            // validated against JPK_V7M(3) 1-0E

Rows accept the register fields directly (amounts with K_10 to K_360) or rate shortcuts (23, 8, 5, 0, zw, np, np_art100, wdt, export, wnt, import_33a, import_services, import_services_28b, reverse_charge). Typed objects (SaleRow, PurchaseRow, Declaration) with enums (Gtu, Procedure, KsefMarker, SaleDocumentType, RefundTerm) are available too.

What the builder computes:

  • LiczbaWierszySprzedazy, PodatekNalezny (without FP rows), LiczbaWierszyZakupow, PodatekNaliczony
  • P_10 to P_47 rounded to whole zloty, P_37, P_38, P_48, P_49 / P_50 capped as the schema documents, P_51, P_53, P_54 with the refund term, P_62, and P_68 / P_69 from rows with a bad debt relief due date
  • FP invoices and negative MR_T / MR_UZ bases are left out of the declaration, as the Ministry of Finance brochure requires

Corrections and quarterly files:

Jpk::v7m(2026, 10)->correction('Missing invoice FV/9')->...;           // CelZlozenia 2 and P_ORDZU
Jpk::v7m(2026, 10)->correction()->withoutDeclaration()->...;           // register only

Jpk::v7k(2026, 12)                                                     // declaration in the last month of the quarter
    ->previousMonths(sales: $octoberAndNovemberSales, purchases: $octoberAndNovemberPurchases)
    ->addSales($decemberSales)
    ->declaration(['refund' => 1500, 'refund_term' => RefundTerm::Days40->value]);

JPK_FA and JPK_MAG

$xml = Jpk::fa()
    ->period('2026-10-01', '2026-10-31')
    ->taxOffice('1471')
    ->subject(['nip' => '5261040828', 'name' => 'ACME sp. z o.o.', 'address' => [
        'voivodeship' => 'mazowieckie', 'county' => 'Warszawa', 'municipality' => 'Warszawa',
        'street' => 'Prosta', 'building' => '1', 'city' => 'Warszawa', 'postal_code' => '00-001',
    ]])
    ->addInvoice([
        'number' => 'FV/1/10/2026', 'issue_date' => '2026-10-05',
        'seller' => ['name' => 'ACME sp. z o.o.', 'address' => 'Prosta 1, 00-001 Warszawa', 'tax_id' => '5261040828'],
        'buyer' => ['name' => 'Klient S.A.', 'address' => 'Krzywa 2, 00-002 Warszawa', 'tax_id' => '1234563218'],
        'lines' => [
            ['name' => 'Usługa', 'quantity' => 2, 'unit' => 'szt', 'unit_net_price' => '100.00', 'rate' => '23'],
            ['name' => 'Książka', 'net' => '50.00', 'rate' => '5'],
        ],
    ])
    ->toXml();

$xml = Jpk::mag()
    ->period('2026-10-01', '2026-10-31')                 // JPK_MAG(1); from 2027-01-01 JPK_MAG(2), or ->variant(2)
    ->taxOffice('1471')->subject($company)->warehouse('MAG-1')
    ->addDocument([
        'type' => 'PZ', 'number' => 'PZ/1', 'date' => '2026-10-02', 'counterparty' => 'Dostawca',
        'lines' => [['name' => 'Śruba M8', 'code' => 'S-M8', 'quantity' => '10.5', 'unit' => 'kg', 'unit_price' => '2.00']],
    ])
    ->toXml();

Sending to the Ministry of Finance gateway

With a qualified signature or Trusted Profile (XAdES-BES, enveloped or enveloping, signed outside the application):

$package = Jpk::prepare($xml, 'JPK_V7M_2026-10.xml');     // validate, zip, encrypt
$unsigned = $package->initUploadXml();                    // sign this document
session(['jpk' => encrypt($package->toArray())]);         // keep it while the user signs (contains the AES key)

$package = \Beziworld\Jpk\Gateway\UploadPackage::fromArray(decrypt(session('jpk')));
$session = Jpk::send($package, $signedInitUploadXml);     // InitUploadSigned, Put Blob, FinishUpload
$status = Jpk::waitForUpo($session->referenceNumber);    // or Jpk::status($reference) from a scheduled job

if ($status->isAccepted()) {
    Storage::put("upo/{$session->referenceNumber}.xml", $status->upo);
}

With authorization data (natural persons only; income from the PIT return for the year two years before the submission year):

use Beziworld\Jpk\Data\AuthorizationData;

$package = Jpk::prepare($xml, authorization: AuthorizationData::make('5261040828', 'Jan', 'Kowalski', '1980-01-02', '123456.78'));
$session = Jpk::send($package);

DocumentType defaults to JPK for JPK_V7 and to JPKAH (on request) for JPK_FA and JPK_MAG. The bundled MF public-key certificates (test and production) are valid until July 2027.

KSeF invoices and helpers

$row = Jpk::converter()->toSaleRow($fa3Xml, ksefNumber: '5261040828-20261104-0100A0B1C2D3-4F');
$row = Jpk::converter()->toPurchaseRow($fa3Xml, ksefNumber: $number, receivedAt: '2026-11-05');

Jpk::taxOffices();                 // ['0202' => 'URZĄD SKARBOWY W BOLESŁAWCU', ...] from KodyUrzedowSkarbowych_v8-0E
Jpk::taxOffice('1471');           // 'PIERWSZY MAZOWIECKI URZĄD SKARBOWY W WARSZAWIE'
Jpk::validate($xml);              // Schema::V7M3, or InvalidDocument with errors()

$request->validate(['office' => ['required', new \Beziworld\Jpk\Rules\TaxOfficeCode()]]);

The converter maps rates 23/22, 8/7, 5, 0 % (domestic, intra-EU, export), exempt and outside-Poland supplies, GTU and procedure markings, TP and FP flags, and requires an exchange rate for foreign currency invoices. Flat-rate taxi tax, OSS, domestic reverse charge and margin scheme amounts raise UnsupportedConversion.

php artisan jpk:validate storage/jpk/JPK_V7M_2026-10.xml
php artisan jpk:status d4fd41850323d2f6000000b013016327 --wait --upo=storage/upo.xml

Errors

Exception When
InvalidDocument missing data, wrong rows or a file that does not match the XSD (errors() lists libxml messages with line numbers)
ApiException gateway or Azure error: errorCode() (for example 120 invalid signature, 170 duplicate), httpStatus(), requestId(), errors(); also transport failures and polling timeouts
UnsupportedConversion a KSeF invoice field that needs a manual decision
InvalidConfiguration unknown environment or unreadable certificate

All extend Beziworld\Jpk\Exceptions\JpkException. Status codes 300 and 4xx (rejections) are returned as UploadStatus with isRejected(), not thrown.

Notes

  • Writes (InitUploadSigned, Put Blob, FinishUpload) are never retried; status reads are retried on connection errors and HTTP 5xx.
  • The official page currently links a draft JPK_V7M(3) schema under "initupload"; the InitUpload structure follows the 2016 MF schema and the official 2026 samples, which the tests validate.
  • With authorization data the document is encrypted with the same AES key and IV as the JPK parts; multi-part uploads encrypt every part with the same key and IV. The specification describes the key but does not spell out the IV for these cases.

Development

composer install
composer ci   # pint --test, phpstan (level 8), pest

Author

Created and maintained by Kamil Ćwiertnia, beziworld.eu.

Need an integration? Get in touch through beziworld.eu.

Other packages by the author

laravel-ksef · laravel-company-data · laravel-polish-validation · laravel-nbp

License

MIT. See LICENSE.md. This package is not affiliated with the Ministry of Finance or the National Revenue Administration (KAS).