Skip to content
Back to Blog
Laravel MyInvois LHDN e-Invoicing

10 Phases of MyInvois Integration: What LHDN Doesn't Tell You

Nur Ikhwan Idris · · 10 min read

If you've read my earlier post on MyInvois API integration, you know the basics: OAuth2 tokens, UBL documents, status polling. That post covered the fundamentals. This one goes deeper — into the 10 implementation phases I went through while building full MyInvois compliance in AutoKira v2, and all the things LHDN's documentation conveniently glosses over.

AutoKira is an invoicing system for Malaysian businesses. By the time I was done, it handled standard invoices, credit notes, debit notes, self-billed invoices, consolidated B2C invoices, PDF generation with QR codes, automated buyer emails, and a 72-hour cancellation guard that — spoiler — I managed to break twice before getting right.

This isn't a theoretical walkthrough. Every code snippet here is from production. Every gotcha cost me at least a few hours.

1. The 10 Phases Overview

LHDN's MyInvois rollout has 10 phases, and each phase adds new document types and requirements. Here's the breakdown as I implemented them:

  1. Phase 1: Standard Invoice (type 01)
  2. Phase 2: Credit Note (type 02) and Debit Note (type 03)
  3. Phase 3: Refund Note (type 04)
  4. Phase 4: Self-Billed Invoice (type 11)
  5. Phase 5: Self-Billed Credit Note (type 12)
  6. Phase 6: Self-Billed Debit Note (type 13)
  7. Phase 7: Self-Billed Refund Note (type 14)
  8. Phase 8: Consolidated e-Invoice
  9. Phase 9: PDF generation with LHDN QR codes
  10. Phase 10: Automated validation emails to buyers

Each phase builds on the previous one. You can't just jump to Phase 8 — the consolidated invoice logic depends on the same UBL builder, signing flow, and status polling that you build in Phase 1. Get the foundation right and the rest follows.


2. UBL XML Building: The UblBuilder Class

The heart of the system is the UblBuilder class. LHDN expects UBL 2.1 documents with their specific namespace structure. I built a dedicated builder that constructs the entire document tree immutably — each method returns a new array rather than mutating state.

class UblBuilder
{
    public function build(Invoice $invoice, Workspace $workspace): array
    {
        return [
            '_D' => 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2',
            '_A' => 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2',
            '_B' => 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
            'Invoice' => [[
                'ID' => [['_' => $invoice->invoice_number]],
                'IssueDate' => [['_' => $invoice->issue_date->format('Y-m-d')]],
                'IssueTime' => [['_' => $invoice->issue_date->format('H:i:s\Z')]],
                'InvoiceTypeCode' => [['_' => $invoice->type_code, 'listVersionID' => '1.0']],
                'DocumentCurrencyCode' => [['_' => 'MYR']],
                'AccountingSupplierParty' => [$this->buildSupplier($workspace)],
                'AccountingCustomerParty' => [$this->buildCustomer($invoice)],
                'TaxTotal' => [$this->buildTaxTotal($invoice)],
                'LegalMonetaryTotal' => [$this->buildMonetaryTotal($invoice)],
                'InvoiceLine' => $this->buildLines($invoice->items),
            ]],
        ];
    }
}

The key insight: every value in LHDN's UBL JSON format is wrapped in an array of objects with a _ key. Forget one level of nesting and you'll get cryptic validation errors that say nothing useful.

General Public TIN for Consolidated Invoices

For B2C consolidated invoices (Phase 8), the buyer is "General Public." LHDN provides a special TIN for this: EI00000000010. The buyer section uses this TIN with a fixed name:

private function buildConsolidatedCustomer(): array
{
    return [
        'Party' => [[
            'PartyIdentification' => [[
                'ID' => [['_' => 'EI00000000010', 'schemeID' => 'TIN']],
            ]],
            'PartyLegalEntity' => [[
                'RegistrationName' => [['_' => 'General Public']],
            ]],
            'PostalAddress' => [[
                'CityName' => [['_' => 'NA']],
                'CountrySubentityCode' => [['_' => '17']],  // Not applicable
                'Country' => [['IdentificationCode' => [['_' => 'MYS']]]],
            ]],
        ]],
    ];
}

This TIN isn't prominently documented. I found it buried in a PDF appendix. If you use any other TIN for consolidated invoices, LHDN rejects with a generic "invalid buyer" error.


3. Token, Sign, Submit, Poll: The SubmitDocumentService Pattern

Every document submission follows the same four-step flow. I encapsulated this in a SubmitDocumentService that orchestrates the entire pipeline:

class SubmitDocumentService
{
    public function __construct(
        private MyInvoisAuthService $auth,
        private UblBuilder $builder,
        private DocumentSigner $signer,
    ) {}

    public function submit(Invoice $invoice, Workspace $workspace): SubmissionResult
    {
        // 1. Get OAuth token
        $token = $this->auth->getAccessToken();

        // 2. Build UBL document
        $document = $this->builder->build($invoice, $workspace);

        // 3. Sign the document (SHA-256 hash + Base64 encode)
        $signed = $this->signer->sign($document);

        // 4. Submit to LHDN
        $response = Http::withToken($token)
            ->post(config('myinvois.api_url') . '/api/v1.0/documentsubmissions', [
                'documents' => [[
                    'format' => 'JSON',
                    'document' => $signed->encoded,
                    'documentHash' => $signed->hash,
                    'codeNumber' => $invoice->invoice_number,
                ]],
            ]);

        throw_unless($response->successful(), new MyInvoisSubmissionException(
            "Submission failed: {$response->status()} — {$response->body()}"
        ));

        $data = $response->json();

        // 5. Dispatch polling job
        CheckInvoiceSubmissionStatus::dispatch(
            $invoice,
            $data['submissionUID'],
            $data['acceptedDocuments'][0]['uuid'],
        )->delay(now()->addSeconds(5));

        return new SubmissionResult(
            submissionUid: $data['submissionUID'],
            uuid: $data['acceptedDocuments'][0]['uuid'],
        );
    }
}

The DocumentSigner is straightforward — hash the raw JSON string, then Base64-encode it. But the order matters: hash first, then encode. If you encode first and hash the encoded string, LHDN will reject with a hash mismatch.


4. Exponential Backoff Polling

Submission is asynchronous. LHDN doesn't validate your document inline — they queue it. You get a submissionUID back immediately, and then you poll. The CheckInvoiceSubmissionStatus job handles this with exponential backoff:

class CheckInvoiceSubmissionStatus implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 10;

    public function __construct(
        private Invoice $invoice,
        private string $submissionUid,
        private string $documentUuid,
    ) {}

    public function handle(): void
    {
        $token = app(MyInvoisAuthService::class)->getAccessToken();

        $response = Http::withToken($token)
            ->get(config('myinvois.api_url') . "/api/v1.0/documents/{$this->documentUuid}/details");

        if (!$response->successful()) {
            $this->release($this->calculateBackoff());
            return;
        }

        $status = $response->json('status');

        if ($status === 'Valid') {
            $this->invoice->update([
                'lhdn_status' => 'valid',
                'lhdn_uuid' => $this->documentUuid,
                'lhdn_long_id' => $response->json('longId'),
                'validated_date' => now(),
            ]);

            EmailInvoiceToBuyer::dispatch($this->invoice);
            return;
        }

        if ($status === 'Invalid') {
            $this->invoice->update([
                'lhdn_status' => 'invalid',
                'lhdn_rejection_reason' => $response->json('documentStatusReason'),
            ]);
            return;
        }

        // Still processing — release with backoff
        $this->release($this->calculateBackoff());
    }

    private function calculateBackoff(): int
    {
        // 5s, 10s, 20s, 40s, 80s, 160s...
        return (int) (5 * pow(2, $this->attempts() - 1));
    }
}

Why exponential backoff? Because LHDN's sandbox validates in seconds, but production can take up to 2 minutes under load — especially during month-end when every business in Malaysia is submitting at once. Linear polling wastes API calls and risks hitting rate limits.


5. The 72-Hour Cancellation Window

Once LHDN validates an invoice, you have exactly 72 hours to cancel it. After that window closes, the only option is issuing a credit note. The CancelDocumentService enforces this:

class CancelDocumentService
{
    public function cancel(Invoice $invoice, string $reason): void
    {
        // Guard: check 72-hour window BEFORE anything else
        throw_unless(
            $invoice->validated_date !== null,
            new CancellationException('Invoice has not been validated by LHDN.')
        );

        $hoursSinceValidation = $invoice->validated_date->diffInHours(now());

        throw_if(
            $hoursSinceValidation > 72,
            new CancellationException(
                "Cancellation window expired. Invoice was validated {$hoursSinceValidation} hours ago. "
                . "Issue a credit note instead."
            )
        );

        $token = app(MyInvoisAuthService::class)->getAccessToken();

        $response = Http::withToken($token)
            ->put(
                config('myinvois.api_url') . "/api/v1.0/documents/state/{$invoice->lhdn_uuid}",
                ['status' => 'cancelled', 'reason' => $reason]
            );

        throw_unless($response->successful(), new CancellationException(
            "LHDN cancellation failed: {$response->body()}"
        ));

        $invoice->update(['lhdn_status' => 'cancelled']);
    }
}

The critical detail: you must check the window before making the API call. LHDN will also reject expired cancellations, but by then you've already used an API call and the error message from their side is vague. Validate locally first, fail fast with a clear message.


6. Self-Billed Invoice Types (11, 12, 13, 14)

Self-billed invoices are where things get interesting. In a normal invoice, the supplier issues the document. In self-billing, the buyer issues the invoice on behalf of the supplier. This is common in Malaysia for insurance claims, agricultural purchases, and commission-based arrangements.

The four self-billed types map directly to the standard types:

  • Type 11: Self-Billed Invoice (mirrors type 01)
  • Type 12: Self-Billed Credit Note (mirrors type 02)
  • Type 13: Self-Billed Debit Note (mirrors type 03)
  • Type 14: Self-Billed Refund Note (mirrors type 04)

The implementation twist: for self-billed documents, the AccountingCustomerParty (buyer) is auto-filled from the workspace's own workspace_customer_id. The supplier and buyer roles are effectively swapped in the UBL structure:

private function buildCustomer(Invoice $invoice): array
{
    // Self-billed: buyer is the workspace itself
    if (in_array($invoice->type_code, ['11', '12', '13', '14'])) {
        $workspace = $invoice->workspace;
        return [
            'Party' => [[
                'PartyIdentification' => [[
                    'ID' => [['_' => $workspace->customer_tin, 'schemeID' => 'TIN']],
                ]],
                'PartyLegalEntity' => [[
                    'RegistrationName' => [['_' => $workspace->company_name]],
                ]],
            ]],
        ];
    }

    // Standard: buyer is the invoice customer
    return $this->buildStandardCustomer($invoice->customer);
}

If you try to submit a self-billed invoice with a regular customer as the buyer, LHDN rejects it because the buyer TIN doesn't match the submitting taxpayer. This cost me half a day of debugging because the error message just said "invalid party identification."


7. Consolidated Invoices for B2C

For businesses that deal primarily with walk-in customers (retail, F&B, clinics), LHDN allows consolidated invoices — a single submission that aggregates all B2C transactions for a period. In AutoKira, this is a separate model with its own submission service:

class ConsolidatedInvoice extends Model
{
    protected $fillable = [
        'workspace_id',
        'period_start',
        'period_end',
        'total_amount',
        'total_tax',
        'transaction_count',
        'lhdn_status',
        'lhdn_uuid',
        'lhdn_long_id',
        'validated_date',
    ];

    protected $casts = [
        'period_start' => 'date',
        'period_end' => 'date',
        'total_amount' => 'decimal:2',
        'total_tax' => 'decimal:2',
        'validated_date' => 'datetime',
    ];

    public function transactions(): HasMany
    {
        return $this->hasMany(ConsolidatedTransaction::class);
    }
}

The ConsolidatedSubmissionService follows the same Token-Sign-Submit-Poll pattern but uses the General Public TIN (EI00000000010) as the buyer. Each line item in the UBL document represents a summarised category — not individual transactions. You aggregate by SST tax code and description.

// Aggregate transactions by tax category
$lines = $consolidated->transactions
    ->groupBy('tax_category_code')
    ->map(function ($group, $taxCode) {
        return [
            'description' => "Consolidated sales — SST {$taxCode}",
            'quantity' => $group->sum('quantity'),
            'amount' => $group->sum('amount'),
            'tax_amount' => $group->sum('tax_amount'),
            'tax_code' => $taxCode,
        ];
    })
    ->values()
    ->all();

Why a separate model instead of reusing Invoice? Because the lifecycle is different. A consolidated invoice doesn't have a single customer, doesn't generate a buyer email, and the line items are aggregates. Trying to shoehorn this into the standard invoice model would have created a mess of conditionals.


8. PDF Generation with QR Codes

LHDN requires every validated invoice PDF to include a QR code that links to the LHDN validation page. The URL format is https://myinvois.hasil.gov.my/{uuid}/share/{longId}. I use DomPDF for the PDF and BaconQrCode for generating the QR as an SVG data URI — no temp files needed:

use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;

class InvoicePdfGenerator
{
    public function generate(Invoice $invoice): string
    {
        $qrUrl = "https://myinvois.hasil.gov.my/{$invoice->lhdn_uuid}/share/{$invoice->lhdn_long_id}";

        $renderer = new ImageRenderer(
            new RendererStyle(200),
            new SvgImageBackEnd()
        );

        $writer = new Writer($renderer);
        $svgString = $writer->writeString($qrUrl);
        $dataUri = 'data:image/svg+xml;base64,' . base64_encode($svgString);

        $pdf = Pdf::loadView('invoices.pdf', [
            'invoice' => $invoice,
            'qrDataUri' => $dataUri,
        ]);

        return $pdf->output();
    }
}

In the Blade template, the QR code is rendered as a plain <img> tag with the data URI as the source. DomPDF handles SVG data URIs natively — no need for PNG conversion:

<div class="qr-code">
    <img src="{{ $qrDataUri }}" width="150" height="150" />
    <p>Scan to verify on LHDN MyInvois</p>
</div>

One gotcha: DomPDF versions before 2.0.4 have a bug where SVG data URIs with certain characters cause a blank image. Make sure you're on a recent version or fall back to PNG via ImagickImageBackEnd.


9. Automated Email on Validation

When the polling job detects a Valid status, it dispatches an EmailInvoiceToBuyer job. This generates the PDF and sends it as an attachment:

class EmailInvoiceToBuyer implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(private Invoice $invoice) {}

    public function handle(InvoicePdfGenerator $generator): void
    {
        $pdf = $generator->generate($this->invoice);

        Mail::to($this->invoice->customer->email)
            ->send(new InvoiceValidatedMail(
                invoice: $this->invoice,
                pdfContent: $pdf,
            ));

        $this->invoice->update(['emailed_at' => now()]);
    }
}

The email includes the LHDN validation URL so the buyer can independently verify the invoice. This is a requirement in the MyInvois spec — the buyer must be able to access the validated document.


10. The Status Flow

Understanding the full status lifecycle is critical for building the right UI. Here's the complete flow:

draft → sent → submitted → valid → paid
                                  ↘ invalid    ↘ cancelled
                                  ↘ rejected
  • draft: Invoice created in the system, not yet submitted to LHDN
  • sent: Submitted to LHDN API, awaiting submissionUID
  • submitted: LHDN accepted the submission, polling for validation
  • valid: LHDN validated the document — QR code and UUID are now available
  • invalid: LHDN rejected the document — check documentStatusReason
  • rejected: Buyer rejected the document (separate from LHDN validation)
  • cancelled: Cancelled within 72-hour window
  • paid: Payment received (application-level status, not LHDN)

In the UI, I only show the "Cancel" button when lhdn_status === 'valid' and the 72-hour window is still open. After 72 hours, the button changes to "Issue Credit Note." This prevents user confusion and avoids wasted API calls.


11. Testing: Real RSA Certificates in Tests

LHDN's document signing uses RSA certificates. In production, you'd use a real cert issued by a recognised CA. For tests, I generate ephemeral certs using PHP's OpenSSL functions:

trait GeneratesTestCertificate
{
    protected function generateTestCertificate(): array
    {
        $privateKey = openssl_pkey_new([
            'private_key_bits' => 2048,
            'private_key_type' => OPENSSL_KEYTYPE_RSA,
        ]);

        $csr = openssl_csr_new([
            'commonName' => 'Test Taxpayer',
            'countryName' => 'MY',
        ], $privateKey);

        $certificate = openssl_csr_sign($csr, null, $privateKey, 365);

        openssl_x509_export($certificate, $certPem);
        openssl_pkey_export($privateKey, $keyPem);

        return [
            'certificate' => $certPem,
            'private_key' => $keyPem,
        ];
    }
}

This lets tests exercise the full signing pipeline without needing real certificates or mocking the crypto layer. The generated cert is valid for the test's lifetime and discarded after.


12. Common Gotchas

Here are the things that aren't in the docs — or are buried where you won't find them until it's too late:

Sandbox vs Production Differences

  • Sandbox validates in 1-3 seconds. Production can take 30 seconds to 2 minutes.
  • Sandbox TINs are from a specific test dataset. Production TINs are verified against SSM records.
  • Sandbox rate limits are more generous. Production will throttle you during peak hours (month-end, quarter-end).
  • Some validation rules are stricter in production — fields that sandbox accepts as empty will be rejected in prod.

Rate Limiting

LHDN doesn't publish their rate limit numbers. Through experimentation, I found that bursts of more than 20 submissions per minute trigger HTTP 429 responses. The solution: queue submissions with a 3-second delay between dispatches and implement exponential backoff on 429 responses.

XML Namespace Issues

If you're submitting in XML format instead of JSON, the namespace prefixes must match exactly what LHDN expects. Using cbc: instead of the expected prefix — or missing the ext:UBLExtensions namespace for signed documents — causes silent validation failures where the API returns Valid for the submission but the document status never resolves.


13. Bug Story: The 72-Hour Guard Bypass

This is the most instructive bug I hit during the entire implementation. The 72-hour cancellation guard — which I showed in section 5 — had two separate bypass paths that allowed cancellation of invoices outside the window.

Bug #1: CSV Import Path

AutoKira has a CSV import feature for bulk invoice operations, including bulk cancellation. The CSV import controller had its own cancellation logic that didn't go through CancelDocumentService. Instead, it called the LHDN API directly. But before making the API call, it updated the invoice record — including clearing validated_date to null as part of a "reset" step:

// THE BUG — CSV import controller
foreach ($rows as $row) {
    $invoice = Invoice::where('invoice_number', $row['invoice_number'])->first();

    // This cleared validated_date BEFORE the 72-hour check
    $invoice->update([
        'lhdn_status' => 'cancelling',
        'validated_date' => null,  // Oops.
    ]);

    // CancelDocumentService checks validated_date... which is now null
    app(CancelDocumentService::class)->cancel($invoice, $row['reason']);
}

Because validated_date was set to null before CancelDocumentService ran, the guard threw a different exception — "Invoice has not been validated" — instead of the 72-hour check. The catch block for that exception treated it as a retryable error and pushed the cancellation through directly to the API.

Bug #2: API Endpoint Path

The second bug was in the API controller. A PUT request to cancel an invoice went through middleware that loaded the invoice with a fresh query. But the middleware also called a refreshLhdnStatus() helper that, as a side effect, nullified validated_date when the LHDN status came back as anything other than "Valid" — which includes the brief "Cancelled" transition state:

// THE BUG — refreshLhdnStatus helper
public function refreshLhdnStatus(Invoice $invoice): Invoice
{
    $currentStatus = $this->fetchStatusFromLhdn($invoice->lhdn_uuid);

    // This cleared validated_date for any non-Valid status
    return $invoice->fill([
        'lhdn_status' => strtolower($currentStatus),
        'validated_date' => $currentStatus === 'Valid' ? $invoice->validated_date : null,
    ]);
}

If a race condition caused the status refresh to happen mid-cancellation — when LHDN had already started processing the cancel — the validated_date got wiped, and the 72-hour guard was bypassed.

The Fix

Both bugs had the same root cause: mutating validated_date before the cancellation guard ran. The fix was twofold:

  1. Never clear validated_date. It's a historical fact — the date LHDN validated the document. It should never be nullified, even on cancellation. I added a model-level protection using an updating event that prevents setting it to null once set.
  2. All cancellation paths go through CancelDocumentService. The CSV import and API controller both now delegate to the same service. No more duplicate cancellation logic.
// Model event: protect validated_date from being cleared
protected static function booted(): void
{
    static::updating(function (Invoice $invoice) {
        if ($invoice->isDirty('validated_date') && $invoice->validated_date === null) {
            $original = $invoice->getOriginal('validated_date');
            if ($original !== null) {
                $invoice->validated_date = $original;
            }
        }
    });
}

This bug taught me two things: always funnel critical operations through a single service class, and never mutate historical timestamps. If LHDN validated that invoice on Tuesday at 3pm, that fact doesn't change just because you're cancelling it on Thursday.


Wrapping Up

Building full MyInvois compliance isn't just about calling an API. It's 10 interconnected phases that touch document building, cryptographic signing, asynchronous polling, cancellation guards, PDF rendering, email delivery, and — if you're unlucky — debugging race conditions in your own cancellation logic at 2am.

The LHDN documentation gives you the API spec. It doesn't tell you about the General Public TIN, the rate limits during month-end, the sandbox-vs-production behavioral differences, or the fact that clearing a timestamp before checking it will let your users cancel invoices they shouldn't be able to.

If you're starting a MyInvois integration, start with Phase 1 and build up. Get the Token-Sign-Submit-Poll flow bulletproof before adding document types. Use a single service class for each critical operation. And write tests with real RSA certs — don't mock the crypto.

Questions or war stories from your own MyInvois integration? Reach out via the contact section.