The package is a thin Laravel wrapper around Intuit's quickbooks/v3-php-sdk. Almost everything you'll debug lives in the SDK, not in this package, so get the wrapper wired up and then read the SDK docs.
Install (Laravel 8+, PHP 8.0+):
composer require jarvus/laravel-quickbooks
Auto-discovery registers Jarvus\LaravelQuickBooks\ServiceProvider. On older Laravel add it to config/app.php manually.
Publish config and migrations — the config is the source of truth for client id/secret/redirect/environment, and the package persists OAuth tokens in the database:
php artisan vendor:publish --provider="Jarvus\LaravelQuickBooks\ServiceProvider"
php artisan migrate
Set env (QUICKBOOKS_CLIENT_ID, QUICKBOOKS_CLIENT_SECRET, QUICKBOOKS_REDIRECT_URI, QUICKBOOKS_ENVIRONMENT=development|production), and add the Intuit redirect URI to your app in the Intuit developer dashboard exactly — a trailing slash mismatch is the #1 cause of "invalid redirect_uri".
First use case: prove the connection. Point a browser at the package's OAuth connect route (check routes/ in the package for the exact path), authorize against a QuickBooks sandbox company, land back on the callback, and confirm a token row exists for that realm_id. Then, in php artisan tinker:
$ds = app(\Jarvus\LaravelQuickBooks\QuickBooksManager::class)->dataService();
$customer = new \QuickBooksOnline\API\Data\IPPCustomer();
$customer->DisplayName = 'Acme Test ' . now()->timestamp;
$created = $ds->Add($customer);
dump($created->Id, $ds->getLastError());
If you get an Id back, the plumbing works and everything from here is just SDK usage.
Where to look first: config/quickbooks.php for the key names you'll actually be reading, then the package's src/ (it's a handful of files — the facade accessor tells you what's bound), then the Intuit SDK's DataService docs. Do not expect this package to abstract entities for you.
Talk to DataService, not to the package. The wrapper exists to give you a configured, token-bearing DataService. Everything else — entities, queries, batches — is the SDK. Wrap that in your own repository so you can swap the dependency later:
class QuickBooksClient
{
public function __construct(private DataService $ds) {}
public function upsertCustomer(Customer $local): string
{
$entity = $local->qb_id
? $this->ds->FindById('Customer', $local->qb_id)
: new IPPCustomer();
$entity->DisplayName = $local->name;
$entity->PrimaryEmailAddr = (new IPPEmailAddress())->setAddress($local->email);
if ($local->qb_id) {
$entity->Id = $local->qb_id;
$entity->SyncToken = $local->qb_sync_token; // required on update
}
$saved = $local->qb_id ? $this->ds->Update($entity) : $this->ds->Add($entity);
if (!$saved) { throw new QuickBooksSyncException($this->ds->getLastError()); }
$local->update(['qb_id' => $saved->Id, 'qb_sync_token' => $saved->SyncToken]);
return $saved->Id;
}
}
Store the QuickBooks Id and SyncToken on your own models. This is the single most important design decision. Without a persisted qb_id you'll end up doing DisplayName lookups and creating duplicates; without SyncToken every update will fail with a stale-object error.
Sync through queued jobs, one job per record. QuickBooks calls are slow (200–800ms) and rate-limited; doing them inline in a web request will time out. Jobs give you retries for free:
class PushCustomerToQuickBooks implements ShouldQueue
{
public $tries = 5;
public $backoff = [10, 30, 120, 600];
public function __construct(public int $customerId) {}
public function handle(QuickBooksClient $qb) { $qb->upsertCustomer(Customer::findOrFail($this->customerId)); }
}
Multi-tenant / multi-realm: QuickBooks tokens are per-realm_id. If you serve more than one company file, don't rely on a single global binding — resolve the token for the current tenant and call $dataService->updateOAuth2Token($tokenObj) before each job. Cache the token object per realm for the duration of the worker.
Pull as well as push. $ds->Query("SELECT * FROM Invoice WHERE Metadata.LastUpdatedTime > '...'") is the cheapest way to do incremental syncs. Persist a high-water mark per entity type and schedule an artisan command every 15 minutes.
Testing: bind a fake DataService/manager in the container and assert on the entities passed to Add/Update. Never let the test suite hit Intuit — the sandbox is shared, rate-limited, and slow. Keep one manual end-to-end check against a sandbox realm.
composer.json, and assume you are the maintainer. If you need a supported path, either fork it (it's small) or move to quickbooks/v3-php-sdk directly plus your own tiny token model — the wrapper's value is mostly the config + token persistence.->lockForUpdate()) so concurrent jobs don't race and invalidate each other.intuit-signature HMAC against your verifier token, then queue.Development and Production are distinct hosts and distinct credentials. Sandbox tokens don't work against production. Log which environment each request used; a misconfigured env produces confusing 401s that look like auth bugs.minorversion. The SDK defaults to a minor version that may not be the latest; set it explicitly in config (minorVersion / setMinorVersion) so response shapes don't shift under you between deploys.429 with Retry-After; queue backoff should be exponential, not fixed. Batch writes where you can — it's a 10x throughput difference on bulk imports.getLastError() is not an exception. The SDK returns null on failure and stashes an IdsException/ServiceException. Every write must check the return value. Log getLastError()->getResponseBody() — the Intuit error JSON names the offending field and is the only useful debugging signal.Id + SyncToken; deletes are soft. $ds->Delete($entity) marks Active = false; your queries must add WHERE Active IN (true, false) if you want to see them, and your local soft-deletes should mirror the QB state rather than hard-deleting.TxnDate is a company-timezone date, not UTC.DisplayName must be unique per realm (and is case-insensitive). Use a deterministic scheme (email, external id embedded) rather than the human name, or you'll fight collisions during backfills.setLogLocation) or enable the debug mode. Turn it on in config/quickbooks.php locally, tail the file, and you'll see the exact request/response for every call — far more useful than the exception messages.DataService is a singleton by default. If you need per-tenant instances (or a decorated client that logs and retries), rebind it in your own provider — that's the intended seam, since the package does little else.How can I help you explore Laravel packages today?