aditto / netsuite-client
Framework-agnostic PHP client for NetSuite's SuiteTalk REST API, with an optional Laravel bridge.
Requires
- php: ^8.2
- firebase/php-jwt: ^7.1
- guzzlehttp/guzzle: ^7.8
- psr/http-client: ^1.0
- psr/http-client-implementation: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1|^2.0
- psr/simple-cache: ^2.0|^3.0
Requires (Dev)
- illuminate/support: ^10.0|^11.0|^12.0
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^11.0
Suggests
- illuminate/support: Required to use the Laravel service provider and facade bridge.
This package is auto-updated.
Last update: 2026-08-26 22:02:27 UTC
README
Framework-agnostic PHP client for NetSuite's SuiteTalk REST API, with an optional
Laravel bridge (service provider + config publishing). Built to replace SOAP-based
NetSuite integrations (e.g. netsuitephp/netsuite-laravel) ahead of Oracle's SOAP
deprecation (2026.1 stops including new SOAP endpoints by default, 2027.1 disallows
new SOAP integrations).
Status
All v1 features are implemented and unit tested: NetSuiteConfig, the exception
hierarchy, the Laravel service provider + NetSuite facade, both authentication
methods (OAuth 2.0 Client Credentials/M2M and Token-Based Authentication), HTTP
transport (auth-applying HttpClient + 429-retry RetryMiddleware), response parsing
(ResponseParser + NetSuiteRecord + NetSuiteCollection, with status/error-payload
→ exception mapping), and RecordClient itself
(get/create/update/replace/delete/query).
Token-Based Authentication has been validated against a real NetSuite sandbox: full
customer CRUD round-trip and paginated SuiteQL both pass (composer test:sandbox —
see tests/Sandbox/README.md; these tests aren't committed to the repo by design).
OAuth 2.0 M2M is built to NetSuite's documented spec but not yet confirmed against a
live account.
Requirements
- PHP 8.2+
- Laravel 10, 11, or 12 (only if using the Laravel bridge — the core client has no hard Laravel dependency)
Installation
composer require aditto/netsuite-client
In a Laravel app, publish the config file:
php artisan vendor:publish --tag=netsuite-client-config
NetSuite-side setup
You need an Integration record and a way to authenticate as it — either Token-Based Authentication (TBA) or OAuth 2.0 Client Credentials (M2M). TBA is simpler to set up (no certificate management) and is what this package has actually been validated against; OAuth2 M2M is built to NetSuite's documented spec but not yet confirmed live (see Status above).
Full step-by-step walkthroughs for each method live in docs/:
The summary below covers both together; use the dedicated guide above for the full click-by-click process.
1. Enable features
Setup → Company → Enable Features → SuiteCloud, turn on:
- REST Web Services
- Token-Based Authentication (if using TBA)
- OAuth 2.0 (if using OAuth2 M2M)
2. Create an Integration record
Setup → Integration → Manage Integrations → New.
- Name it (e.g. "netsuite-client").
- Under Authentication, check Token-Based Authentication for TBA, or OAuth 2.0 with the Client Credentials (Machine to Machine) Grant checkbox for OAuth2 M2M.
- Save. NetSuite shows a Consumer Key/Consumer Secret (TBA) or Client ID (OAuth2) — copy these now, the secret is only shown once.
3a. TBA: create an Access Token
Setup → Users/Roles → Access Tokens → New.
- Pick the Integration record from step 2, a user, and a role with REST Web Services login access plus whatever record-level permissions you need (see step 4).
- Save. NetSuite shows a Token ID/Token Secret — copy these now, the secret is
only shown once. These map to
NETSUITE_TOKEN_ID/NETSUITE_TOKEN_SECRET; the Consumer Key/Secret from step 2 map toNETSUITE_CONSUMER_KEY/NETSUITE_CONSUMER_SECRET.
3b. OAuth2 M2M: generate and upload a certificate
M2M authenticates via an ES256-signed JWT, so you need an EC (P-256) keypair — NetSuite verifies the JWT against a certificate you upload, it never sees your private key.
openssl ecparam -name prime256v1 -genkey -noout -out netsuite-private-key.pem
openssl req -new -x509 -key netsuite-private-key.pem -out netsuite-cert.pem -days 730 -subj "/CN=netsuite-client"
Upload netsuite-cert.pem under the Integration record's Certificates tab —
NetSuite assigns a Certificate ID (NETSUITE_CERTIFICATE_ID). The Client ID from
step 2 is NETSUITE_CLIENT_ID. Keep netsuite-private-key.pem out of version control
(NETSUITE_PRIVATE_KEY_PATH points to it on disk) — certificates are valid up to 2
years and rotation is manual for v1 (see the project outline's non-goals).
4. Grant role permissions
Whichever role is tied to the Access Token (TBA) or the Integration record's user
(OAuth2) needs REST Web Services login access, plus standard View/Create/Edit/Full
permissions for every record type you'll touch through this client — NetSuite
enforces these the same way for REST as it does for the UI. If query() fails with a
permissions-flavored error, check this role has access to the tables referenced in
your SuiteQL.
Sandbox vs. production
There's no separate sandbox/production endpoint config — the API host is derived
entirely from NETSUITE_ACCOUNT_ID:
- Sandbox account IDs look like
1234567_SB1→ host1234567-sb1.suitetalk.api.netsuite.com - Production account IDs look like
1234567→ host1234567.suitetalk.api.netsuite.com
NETSUITE_ENVIRONMENT (sandbox/production) doesn't affect the URL — it's a
declared-intent safety flag. In particular, tests/Sandbox/SandboxTestCase refuses to
run against a config with environment=production, as a backstop against ever
pointing the live-sandbox test harness at a real account by accident.
Configuration
Set the following in .env (see .env.example for the full list):
NETSUITE_ACCOUNT_ID=
NETSUITE_AUTH_METHOD=tba
NETSUITE_CONSUMER_KEY=
NETSUITE_CONSUMER_SECRET=
NETSUITE_TOKEN_ID=
NETSUITE_TOKEN_SECRET=
Usage
use Ditto\NetSuiteClient\Laravel\Facades\NetSuite; $customer = NetSuite::get('customer', '123'); $customer->get('companyName'); $created = NetSuite::create('customer', ['companyName' => 'Acme Co']); NetSuite::update('customer', '123', ['email' => 'new@example.com']); NetSuite::delete('customer', '123'); $results = NetSuite::query("SELECT id, companyName FROM customer WHERE isinactive = 'F'"); foreach ($results as $row) { // $row is a raw associative array — SuiteQL rows have no fixed record shape. }
Outside Laravel, construct the pieces directly:
use Ditto\NetSuiteClient\Auth\AuthMethod; use Ditto\NetSuiteClient\Auth\TokenBasedAuthStrategy; use Ditto\NetSuiteClient\Http\EndpointBuilder; use Ditto\NetSuiteClient\Http\HttpClient; use Ditto\NetSuiteClient\NetSuiteConfig; use Ditto\NetSuiteClient\RecordClient; use Ditto\NetSuiteClient\Responses\ResponseParser; $config = new NetSuiteConfig( accountId: '1234567_SB1', authMethod: AuthMethod::TokenBasedAuth, consumerKey: '...', consumerSecret: '...', tokenId: '...', tokenSecret: '...', ); $client = new RecordClient( new HttpClient($config, new TokenBasedAuthStrategy($config)), new EndpointBuilder($config), new ResponseParser(), ); $client->get('customer', '123');
A note on field names
NetSuite's REST API returns field names in camelCase (companyName,
entityId) in every response body, regardless of what casing was accepted on a
write. NetSuiteRecord and ResponseParser pass fields through exactly as NetSuite
sent them — this client is intentionally schema-agnostic, it doesn't know or
normalize any record type's field names. Always read fields back using the casing
NetSuite actually returns (check a real response, or NetSuite's REST record browser),
not whatever casing happened to work on a write.
Logging requests and responses
For local debugging, every REST request/response pair can be logged to disk as a
correlated pair of plain-text files ({id}.request.txt / {id}.response.txt), one
attempt per pair — including each retried attempt on a 429, not just the final one.
Authorization headers are redacted before writing. This is off by default and meant
for local debugging, not production traffic.
In Laravel, enable it via config/env:
NETSUITE_LOG_REQUESTS=true
NETSUITE_LOG_PATH=/path/to/log/dir # defaults to storage_path('logs/netsuite')
Outside Laravel, compose LoggingMiddleware into the client chain yourself:
use Ditto\NetSuiteClient\Http\FileRequestLogger; use Ditto\NetSuiteClient\Http\HttpClient; use Ditto\NetSuiteClient\Http\LoggingMiddleware; use Ditto\NetSuiteClient\Http\RetryMiddleware; use GuzzleHttp\Client as GuzzleClient; $client = new HttpClient($config, $authStrategy, new RetryMiddleware( new LoggingMiddleware(new GuzzleClient(), new FileRequestLogger('/path/to/log/dir')), maxAttempts: $config->maxAttempts, ));
Migrating from SOAP (netsuitephp/netsuite-php)
Most SOAP calls collapse to a single get/create/update/replace/delete/query
call, since SOAP's typed request/response objects mostly boil down to "record type +
field values." Example — a method that unsubscribes a contact from email by clearing a
custom field:
// Before (netsuitephp/netsuite-php, SOAP) public function unsubscribeContact(object $contact): void { $contactRecord = new Contact; $contactRecord->internalId = $contact->internalId; $customFields = new CustomFieldList; $subscriberStatus = new BooleanCustomFieldRef; $subscriberStatus->value = false; $subscriberStatus->scriptId = 'custentity_email_subscribed'; $customFields->customField[] = $subscriberStatus; $contactRecord->customFieldList = $customFields; $request = new UpdateRequest; $request->record = $contactRecord; $updateResponse = $this->service->update($request); if (! $updateResponse->writeResponse->status->isSuccess) { throw new RuntimeException('Unable to update the Contact.'); } }
// After (this package, REST) public function unsubscribeContact(object $contact): void { $this->recordClient->update('contact', $contact->internalId, [ 'custentity_email_subscribed' => false, ]); }
The if (! ...isSuccess) { throw ... } check has no equivalent to write — update()
already throws a typed exception (NetSuiteValidationException, NetSuiteAuthException,
etc., all extending NetSuiteException) on any non-2xx response, so a plain call that
returns normally means it succeeded. RecordClient never needed to know anything
about the Contact record type or the custentity_email_subscribed field — that's the
point of the generic-client design (see the project outline's "explicitly out of
scope" section on typed per-record classes).
Troubleshooting auth
NetSuiteAuthExceptionon every request — usually bad credentials, a role missing REST Web Services login access, or (TBA specifically) system clock skew: TBA signs each request with a timestamp, and NetSuite rejects requests signed too far from its own server time.NetSuiteValidationExceptionwith "Please enter value(s) for: X" on create/update — this is NetSuite telling you a field is mandatory for this account's configuration (subsidiary, custom mandatory fields, the form in use) — not a client bug. Inspect$exception->errorDetailsfor NetSuite's full structured error list. OneWorld (multi-subsidiary) accounts commonly requiresubsidiary, and sometimes an account-specific custom field for location — there's no generic way to know these ahead of time; discover them from the error details, or from NetSuite's UI record form for that type.- SuiteQL (
query()) failing withINVALID_HEADER/ "The required request header 'Prefer' is missing" — already handled internally (RecordClient::query()always sendsPrefer: transient); this only bites if you're calling the query endpoint some other way. NetSuiteRateLimitException— a 429 that persisted pastRetryMiddleware's retry budget (NetSuiteConfig::$maxAttempts, default 3).$exception->retryAfterSecondscarries what NetSuite'sRetry-Afterheader said, if present.
Testing
composer install vendor/bin/phpunit
Tests against a real NetSuite sandbox (composer test:sandbox) are intentionally not
part of this repo — see tests/Sandbox/README.md.