jiannius/myinvois
A Laravel package for integrating with Malaysia's MyInvois e-Invoicing platform, offering helpers to submit, validate, and manage electronic invoices through the LHDN MyInvois API from PHP applications.
v1.5.0 is additive: submitDocuments() still works exactly as before, so the upgrade itself is a composer update plus a config check. Everything else is opt-in and can be adopted incrementally.
services.myinvois.*. Diff your existing config rather than overwriting it.queue.retry_after (in config/queue.php) must exceed 120s. If it doesn't, a PollSubmission job can be retried while the first attempt is still polling, and you'll get duplicate work.myinvois:sync schedule is only added when services.myinvois.sync_schedule is set. Run php artisan schedule:list and confirm it appears before assuming it's running.php artisan myinvois:sync --limit=50 --dry-run
First use case: take one heavyweight endpoint — typically the controller action that calls submitDocuments() — and move it to the queue. The controller dispatches; a listener on SubmissionAccepted updates your UI. That single change removes the LHDN round-trip (and its timeouts) from the web request, which is the whole point of the release.
If you're not ready for that, still take the fixes: the shared rate limiter, the null-status handling, and the failedCallback null crash are behaviour changes that affect every app.
Dispatch from the controller, react to events. Don't poll from the request either.
public function store(InvoiceRequest $request)
{
$parent = Invoice::create($request->validated());
Myinvois::queueSubmission(
documents: [$parent->toMyinvoisDocument()],
parents: [$parent],
context: ['team_id' => $parent->team_id],
);
return back()->with('status', 'Submitted for validation');
}
Then keep the UI honest with listeners rather than re-reading state:
Event::listen(SubmissionSucceeded::class, fn ($e) => $e->parent->markValid());
Event::listen(SubmissionFailed::class, fn ($e) => $e->parent->markRejected($e->reasons));
Event::listen(DocumentStatusChanged::class, fn ($e) => Cache::forget("doc:{$e->document->id}"));
SubmissionFailed carries LHDN's reasons for INVALID — surface those verbatim to users, they're the only actionable explanation you'll get.
Treat the in-flight lock as a feature, not an obstacle. The package refuses to re-submit a parent whose latest submission is SUBMITTED or VALID. Translate MyinvoisInFlightException into a 409 rather than swallowing it:
try {
Myinvois::queueSubmission([$doc], [$parent], $context);
} catch (MyinvoisInFlightException $e) {
abort(409, 'This invoice is already being submitted.');
}
This is what makes it safe to dispatch from a button a user can double-click.
Per-organisation credentials via the contract. Bind ResolvesMyinvoisClient in a service provider and resolve from context, not from the authenticated user — the worker has no session.
$this->app->bind(ResolvesMyinvoisClient::class, fn () => new OrgClientResolver());
// worker side
$client = Myinvois::clientFor($context['team_id']);
One client per submission is enforced; don't try to swap credentials mid-flight.
Use the sync command as your reconciliation loop, not a webhook. forParents() narrows the sweep, withoutPolling() skips documents still in flight. Default behaviour skips already-VALID parents — add --include-valid only if you're deliberately re-checking them.
Test the whole flow without touching LHDN.
Myinvois::fake()->pollResolvesTo('VALID');
Cover the failure path too — a SubmissionFailed with reasons is the branch most likely to break your UI.
retry_after > 120s is not optional. This is the single most common misconfiguration: polling jobs get retried mid-poll and you burn rate limit for nothing.lock_store must be shared across workers. array or file on a multi-worker setup silently defeats the in-flight lock and you're back to double-submits. Use redis or database.updated_at. If any of your caching, ETags or "recently changed" queries keyed off that touch, they'll go stale. Add an explicit touch() in a DocumentStatusChanged listener where you actually need it — this is the one fix that can regress working code.status no longer blanks the row, and stored validation results are kept. If you had defensive code checking for null status transitions, it's now dead — remove it rather than leaving a wrong assumption in place.unauthorized_client messaging changed to name the on-behalf-of TIN and the environment. Any string matching or error mapping on that code needs re-checking.clientFor() — if your resolver depends on request-scoped state, jobs will fail on the worker only, which is easy to miss locally where the sync driver runs inline.sync_schedule being unset is silent. No schedule, no error. Add schedule:list to your deploy smoke checks.INVALID reasons (unit-tested only), rejectDocument(), and payable_rounding. Exercise rejectDocument() on preprod yourself before depending on it, and eyeball rounding on any invoice where totals land on a half-cent.Extension points worth knowing: ResolvesMyinvoisClient is the intended seam for multi-tenant credentials, and the three submission events are the clean place to hang notifications, audit logs and cache invalidation — resist reaching into the models from your controller instead.
How can I help you explore Laravel packages today?