kayedspace / laravel-erpnext
A fluent ERPNext and Frappe REST client for Laravel: a doctype query builder, typed documents, four authentication schemes and file uploads.
Requires
- php: ^8.3
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
README
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.
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.