kayedspace/laravel-erpnext

A fluent ERPNext and Frappe REST client for Laravel: a doctype query builder, typed documents, four authentication schemes and file uploads.

Maintainers

Package info

github.com/kayedspace/laravel-erpnext

pkg:composer/kayedspace/laravel-erpnext

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1 2026-08-14 12:57 UTC

This package is auto-updated.

Last update: 2026-08-14 13:06:06 UTC


README

Laravel ERPNext

A fluent ERPNext and Frappe client for Laravel — reach any doctype by name, with a query builder, typed documents, four authentication schemes and file uploads.

Latest version Tests PHPStan Downloads License

ERPNext is a generic document store. Every doctype — whether it ships with ERPNext or you invented it this morning — speaks the same REST dialect. This package takes that seriously: you name a doctype and work with it. No subclass, no mapping, no registration.

use Kayedspace\Erpnext\Facades\Erpnext;

Erpnext::doctype('Lead')->create(['lead_name' => 'Ada Lovelace']);

Erpnext::doctype('Sales Invoice')
    ->query()
    ->where('status', 'Overdue')
    ->fields(['name', 'customer', 'outstanding_amount'])
    ->orderBy('due_date', 'asc')
    ->limit(25)
    ->get();

Typed classes exist for the doctypes where named accessors earn their keep, but they are a convenience layer — never a requirement.

use Kayedspace\Erpnext\Documents\SalesInvoice;

$invoice = SalesInvoice::findOrFail('ACC-SINV-2025-00001');

if ($invoice->isDraft()) {
    $invoice->update(['due_date' => now()->addMonth()->toDateString()]);
    $invoice->submit();
}

Installation

composer require kayedspace/laravel-erpnext
php artisan vendor:publish --tag=erpnext-config
ERPNEXT_BASE_URL=https://erp.example.com
ERPNEXT_AUTH_METHOD=token
ERPNEXT_API_KEY=...
ERPNEXT_API_SECRET=...

ERPNEXT_BASE_URL is the bare site root. A value that still carries a trailing /api/resource is accepted and trimmed, so upgrading from a hand-rolled client needs no change to your environment.

What you get

Any doctype, by name. find, findOrFail, create, update, delete, query and call — for Lead, Sales Invoice, Custom Widget, anything.

A query builder that matches Frappe. Frappe has no nested filter groups: filters is the AND bucket, or_filters the OR bucket, and the two are ANDed together. where() and orWhere() fill different buckets and are deliberately not interchangeable, because pretending otherwise produces queries that quietly return the wrong rows.

Pagination that does not lie to you. A list request returns one page, so anything that has to see the whole set walks it:

Erpnext::doctype('Sales Invoice')->query()->where('status', 'Overdue')->each(
    fn (array $invoice) => Chase::dispatch($invoice['name']),
);

each(), chunk() and lazy() request one page at a time and hold one page in memory. count() is the site-wide total, not the size of the page you happened to fetch.

Four authentication schemes.

auth_method Requires Sends
token (default) api_key, api_secret Authorization: token key:secret
basic api_key, api_secret Authorization: Basic base64(key:secret)
bearer access_token Authorization: Bearer token
session username, password a cached sid cookie from /api/method/login

Session logins are cached and re-established automatically — once — when the site rejects an expired session. Stateless schemes never retry, because a second identical request could only fail the same way.

Document name collisions handled for you. Some doctypes take their document name from a field rather than a naming series, so two records with the same name collide on insert. Pass uniqueBy and the client checks first, disambiguating only when it has to. It costs one extra GET on the affected doctypes and nothing at all on the rest.

The submittable lifecycle. docstatus as a real enum, with submit(), cancel() and amend() — and guards so a mistake reads as a sentence rather than a 417 with a traceback in it.

File uploads, attachments and images.

$file = Erpnext::upload()
    ->fromPath(storage_path('app/scan.pdf'))
    ->attachTo('Sales Invoice', 'ACC-SINV-2025-00001', 'custom_scan')
    ->store();

$file->url();       // absolute URL
$file->download();  // bytes, authenticated — so private files work too

Uploads are private by default, matching Frappe. Call ->public() deliberately: a public file is readable by anyone with the URL, not merely by signed-in users.

Retries where they help, and nowhere else. A 429 from the site's rate limiter, a 5xx while bench restarts, a refused connection — all retried. A 4xx is not: it means the request was wrong, and repeating a wrong write is how duplicates get made.

Multi-tenancy without global state. The connection is a closure the client re-invokes on every request, so one long-lived instance can serve many tenants and always authenticates as whoever is active right now.

$this->app->singleton(ErpClient::class, fn ($app) => new ErpClient(
    fn () => Connection::fromArray($app->make(YourTenantSettings::class)->all()),
));

Testing

Everything routes through Laravel's HTTP client, so the usual tools work and no ERPNext site is needed:

Http::preventStrayRequests();
Http::fake(['*' => Http::response(['data' => ['name' => 'CUST-0001']])]);

Erpnext::doctype('Customer')->find('CUST-0001');

Documentation

Full guide and API reference: laravel-erpnext.kayed.dev.

Contributing

See CONTRIBUTING.md. Please note the Code of Conduct.

Security

Found a security issue? Email 3likayed@gmail.com rather than opening a public issue. See SECURITY.md.

Credits

License

The MIT License. See LICENSE.md.