quickbooks/v3-php-sdk
A PHP SDK for the QuickBooks Online API v3, offering OAuth authentication, CRUD operations, query support, and helpers for invoices, customers, payments, and other accounting entities. Simplifies integrating QuickBooks data into Laravel or plain PHP apps.
Check the runtime before you touch composer. v6.4.1 is a patch release that raised the floor twice: PHP >=7.2.5 → >=8.2, and Guzzle ^7.9 → ^7.15.2. Both are hard breaks for anyone on PHP 7.2–8.1 or a pinned older Guzzle 7.x. Verify first, install second:
php -v # must be 8.2+
composer show guzzlehttp/guzzle # must satisfy ^7.15.2
composer why guzzlehttp/guzzle # who else pins it? resolve conflicts before requiring
Only then:
composer require quickbooks/v3-php-sdk:^6.4.1
If you are on Laravel 10 / PHP 8.1, the PHP upgrade is the prerequisite, not the SDK. Laravel 11 and 12 already require PHP 8.2, so the framework upgrade path and the SDK upgrade path line up — plan them together.
The security notice is not about this package. The release bundles Guzzle 7.15.2 to pick up its own vulnerability fixes, but the accompanying advisory (CVE-2026-109618 / CVE-2026-109619, published 2026-10-21) is a PHP runtime RCE affecting any server handling web requests. Installing this SDK version does nothing for it. You need PHP 8.5.12, 8.4.27, 8.3.36 or 8.2.35 (or your distro's patched build) on every host — app servers, queue workers, Horizon, Octane, CI images, and any base image you build from. Track it as a platform patch on its own ticket; do not let "we bumped the SDK" close it.
Configuration entry point is unchanged. The SDK is still a plain PSR-4 library (QuickBooksOnline\API\), not a Laravel package — no facade, no publishable config, no service provider. Everything comes through DataService::configure().
// config/quickbooks.php
return [
'auth_mode' => 'OAuth2',
'client_id' => env('QBO_CLIENT_ID'),
'client_secret' => env('QBO_CLIENT_SECRET'),
'redirect_uri' => env('QBO_REDIRECT_URI'),
'scope' => 'com.intuit.quickbooks.accounting',
'base_url' => env('QBO_ENVIRONMENT', 'Development'), // or 'Production'
'log_level' => env('QBO_LOG_LEVEL', 'ERROR'),
];
First use case: prove connectivity before writing a single line of business logic. Do the OAuth dance once, store the tokens in your database, then bind a factory in a service provider that reconstitutes a DataService from stored tokens.
// app/QuickBooks/QuickBooksFactory.php
use QuickBooksOnline\API\DataService\DataService;
class QuickBooksFactory
{
public function forRealm(QuickBooksToken $token): DataService
{
return DataService::configure([
'auth_mode' => 'OAuth2',
'ClientID' => config('quickbooks.client_id'),
'ClientSecret' => config('quickbooks.client_secret'),
'RedirectURI' => config('quickbooks.redirect_uri'),
'scope' => config('quickbooks.scope'),
'baseUrl' => config('quickbooks.base_url'),
'accessTokenKey' => $token->access_token,
'refreshTokenKey' => $token->refresh_token,
'QBORealmID' => $token->realm_id,
]);
}
}
Smoke test with $qs->Query('select * from CompanyInfo') or FindById(new IPPCustomer(), 'Customer'). Run this first after the upgrade — on PHP 8.2 the SDK's dynamic-property handling and the Guzzle transport are the two things most likely to have shifted. If that returns an entity, your realm, tokens and base URL are all correct.
Where to look in the source. src/DataService/DataService.php is the whole API surface (Query, FindAll, FindById, Add, Update, Delete, Void, CDC, CreateNewBatch/ExecuteBatch). src/Data/ holds the IPP* entities — these are generated and are the real documentation for field names. src/Core/OAuth/OAuth2/OAuth2LoginHelper.php handles authorize/refresh.
Callback route. Intuit redirects back with ?code=...&realmId=...&state=.... Capture realmId — it is your tenant key and you must store it; the SDK will not remember it for you.
public function callback(Request $request)
{
$ds = DataService::configure([...config('quickbooks')...]);
$helper = $ds->getOAuth2LoginHelper();
$token = $helper->exchangeAuthorizationCodeForToken($request->code, $request->realmId);
QuickBooksToken::updateOrCreate(['realm_id' => $request->realmId], [
'access_token' => $token->getAccessToken(),
'refresh_token' => $token->getRefreshToken(),
'access_token_expires_at' => $token->getAccessTokenExpiresAt(),
'refresh_token_expires_at' => $token->getRefreshTokenExpiresAt(),
]);
}
Upgrade in dependency order, and gate it on the smoke test. PHP runtime → Guzzle → SDK, in that sequence, one commit each. A merged PR that bumps all three at once makes a failed Query('select * from CompanyInfo') impossible to attribute. Put the connectivity check in a php artisan quickbooks:health command and run it against the sandbox in CI, so a future breaking change in a patch release is caught by a red build rather than a customer.
Pin the SDK exactly; float everything else. Given that 6.4.1 broke compatibility without a major-version bump, "quickbooks/v3-php-sdk": "6.4.1" (not ^6.4) is the honest constraint. Upgrade deliberately, read the release notes, and let the health command be the gate.
One realm per row, not one token per app. Most Laravel apps get this wrong on the second customer. Model quickbooks_tokens (realm_id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, refresh_token_issued_at) and always resolve the DataService per realm. If you only ever connect your own company, still use the table — it makes testing and re-consent trivial.
Refresh proactively, behind a lock. Access tokens last 60 minutes; refresh tokens rotate and expire (100 days is the ceiling, and each refresh invalidates the old one). Two concurrent jobs refreshing with the same token will revoke each other, and the user must re-consent. Serialize it:
public function fresh(QuickBooksToken $token): DataService
{
if ($token->access_token_expires_at->isAfter(now()->addMinutes(5))) {
return $this->factory->forRealm($token);
}
return Cache::lock("qbo:refresh:{$token->realm_id}", 30)->block(10, function () use ($token) {
$token->refresh(); // re-read inside the lock
if ($token->access_token_expires_at->isAfter(now()->addMinutes(5))) {
return $this->factory->forRealm($token);
}
$helper = $this->factory->forRealm($token)->getOAuth2LoginHelper();
$new = $helper->refreshToken();
$token->update([
'access_token' => $new->getAccessToken(),
'refresh_token' => $new->getRefreshToken(), // must persist — it changed
'access_token_expires_at' => $new->getAccessTokenExpiresAt(),
'refresh_token_expires_at'=> $new->getRefreshTokenExpiresAt(),
]);
return $this->factory->forRealm($token->fresh());
});
}
Wrap the SDK in an interface. DataService is a concrete class with a static constructor, so it is awkward to fake. Bind a thin QuickBooksClient interface (methods: query, find, create, update, delete, batch) and implement it with the SDK. Tests then use a fake or an in-memory recorder instead of hitting Intuit's sandbox. This is the single highest-leverage thing you can do — it makes the SDK swappable, keeps IPP* types out of your domain layer, and means the next 6.4.2-style surprise is a one-file change.
Read-modify-write for every update. QBO is optimistic-concurrency: an update must carry the current Id and SyncToken. Do not build an entity from your local model; fetch it, mutate, save.
$invoice = $dataService->FindById(new IPPInvoice(), 'Invoice', $id); // or ->FindById($entity, 'Invoice')
$invoice->DocNumber = 'INV-1042';
$invoice->PrivateNote = 'Synced from app';
$result = $dataService->Update($invoice);
Map your models to QBO entities in one place per aggregate. An InvoiceSynchronizer that owns "Eloquent invoice → IPPInvoice" (and back) beats sprinkling new IPPLine() across controllers. Line items in particular need the DetailType / AnyIntuitObject dance:
$line = new IPPLine();
$line->Amount = 250.00;
$line->DetailType = 'SalesItemLineDetail';
$line->AnyIntuitObject = new IPPSalesItemLineDetail(); // the SDK reads this property
// then set Description, SalesItemLineDetail->ItemRef, Qty, etc.
$invoice->Line = [$line];
Push writes onto the queue and reads inline. Writes are slow, rate-limited, and retryable; reads are fast enough for a page render (though consider caching CompanyInfo and item/customer lists). Use retry(3) with backoff([10, 60, 300]) and make the job idempotent by checking for an existing qbo_id on the local record before creating.
Batch for bulk work. CreateNewBatch() / AddEntity() / ExecuteBatch() groups up to 30 operations in a single call and counts as one request against the rate limit. Inspect per-operation faults in the response — a batch can succeed at the HTTP level while individual operations fail. Batch is the right tool for a nightly sync; it is not a substitute for a proper CDC.
CDC for incremental sync. $dataService->CDC('Invoice, Customer, Payment', $changedSince) returns only entities changed since a timestamp. This is how you keep a mirror table honest without paging the entire ledger. Persist a last_cdc_at watermark per realm, and remember CDC returns deleted and voided entities too — a naive updateOrCreate will silently resurrect them.
Pagination via query, not FindAll loops. FindAll is convenient but opaque; prefer explicit select * from Invoice STARTPOSITION 1 MAXRESULTS 100 with setMinorVersion() and a loop capped by your own timeout. Set a high minor version (65+) — new fields (tax, GlobalTaxCalculation, some report data) simply do not appear on older versions, which produces "field is null in sandbox but fine in prod" bugs.
Config quirks. baseUrl takes 'Development' or 'Production' (not a literal URL, though a URL also works via ServiceContext::getBaseUrl()). The SDK normalizes config keys, but pick one casing and stick to it — the README contradicts itself between ClientID and client_id and mixed casing is a classic half-hour lost. DataService::configure() returns a new instance every call and is not a singleton; don't register it as one, because the realm is baked in.
6.4.x note as potentially breaking: read it, pin exactly, and let your health command decide whether the upgrade lands. Do not assume semver here.composer why guzzlehttp/guzzle before requiring — an app-level pin, a Laravel minor, or a PDF/mail package can hold you below 7.15.2 and the update will fail at composer update, not at runtime. Resolve that first; don't --ignore-platform-reqs your way past it.php -r 'echo PHP_VERSION;' — inside the container, on the queue worker, in the CI image — not on your laptop. Rebuild base images rather than apt upgrade-ing a running container, and confirm the digest changed.$dataService->throwExceptionOnError(true), or always check $result === null and read $dataService->getLastError(). getLastError() carries the raw response body — log it verbatim, because Intuit's error codes (6240, 5010, 2020) are more useful than the message text.5010 Stale Object Error means your SyncToken is out of date. It is not a bug in the SDK, it is a lost update. Retry the fetch-then-update loop once; if it fails again, someone else is writing to the same record and you need to surface it.Y-m-d for dates and Y-m-d\TH:i:sP for timestamps. A Laravel Carbon cast into an entity field will serialize as 2026-07-17 00:00:00 and Intuit rejects it or (worse) misparses it. Format explicitly at the boundary.invalid_grant from refreshToken() as a terminal state: mark the connection needs_reauth and prompt the user rather than retrying forever in the queue.foreach over invoices will get you throttled (429) with no built-in backoff in the SDK. Add a RateLimiter::for('quickbooks') or a simple sleep-on-429 wrapper around every call site.logs/ directory the SDK creates relative to the document root. In a container that is either permission-denied or silently dropped. Set LogLevel in config and point Laravel at it; don't let the SDK write to its own folder in production — and after a PHP 8.2 bump, check the logs folder is still writable rather than assuming the silence is good news.Void is not Delete. Intuit prefers voiding for anything already sent to a customer; Delete on a posted invoice returns an error. Expose both, and make void the default in your UI.IPPInvoice has public properties plus magic get*/set*, no validation, no casts, and nested IPPLine/IPPReferenceType objects that all need constructing. On PHP 8.2+, dynamic properties on these generated classes are deprecated — if you see Creation of dynamic property ... is deprecated in the log, it is your code setting an undeclared field, not the SDK's. Treat them as DTOs and validate on your side before converting.TxnTaxDetail, GlobalTaxCalculation, and multicurrency (CurrencyRef) fields are either meaningless or actively rejected depending on the company's settings. Feature-detect from CompanyInfo (Country, MultiCurrency preferences) rather than branching on environment.Http::fake() will not intercept this. The SDK drives its own transport, not Laravel's HTTP client, so Guzzle's presence in composer.json does not make it fakeable through the framework. Fake at your QuickBooksClient interface, or stand up the sandbox realm in CI. Never point CI at production with a real token — baseUrl is the only thing standing between the two, and it's a string in an env file.ServiceContext is what actually holds auth + realm, and you can construct one and hand it to DataService if you need to reuse a connection. Core\Interceptors\... lets you hook request/response for logging before the call leaves. Subclassing DataService is possible but usually a mistake — an interface plus a decorator is cleaner in Laravel.QuickBooksOnline\API\Reports\ReportService) and are not reachable through Query. Don't burn an afternoon writing SQL against ProfitAndLoss.src/Data/ after every bump: those classes are regenerated from Intuit's schema and can change shape without appearing in the release notes.How can I help you explore Laravel packages today?