picqer/moneybird-php-client
A PHP client for the Moneybird API that simplifies access to invoices, contacts, products, financial data, and administration resources. It handles authentication and API requests, making Moneybird integration straightforward for Laravel and PHP applications.
Install and configure:
composer require picqer/moneybird-php-client
You need three credentials before you can do anything: client_id, client_secret, and a redirect_url. These come from a Moneybird app you register at moneybird.com/api-applications — the app defines your OAuth redirect URI once, and every administration you later want to touch has to grant it access.
First use case: pull the administrations the token can see.
use Picqer\Financials\Moneybird\Connection;
use Picqer\Financials\Moneybird\Moneybird;
$connection = new Connection();
$connection->setRedirectUrl(config('services.moneybird.redirect'));
$connection->setClientId(config('services.moneybird.client_id'));
$connection->setClientSecret(config('services.moneybird.client_secret'));
// Step 1 – send the user here
header('Location: ' . $connection->getAuthUrl());
exit;
// Step 2 – in your callback route
$connection->parseRedirectUrl(); // reads $_GET['code'] + state
$connection->requestAccessToken(); // exchanges code for token
$token = $connection->getAccessToken(); // persist this + refresh token
$moneybird = new Moneybird($connection);
$administrations = $moneybird->administration()->get();
Where to look first — in order:
Connection — OAuth, token storage, HTTP transport, administration scoping.Moneybird — the facade that returns entity instances (contact, salesInvoice, product, ledgerAccount, taxRate, etc.).Entities/ — every model class; read the $fillable and $supportedMethods properties to know what a model can actually do.Entities/SalesInvoice — the deepest entity; read it first if you need to create invoices, not just read data.Once you can list administrations, the very next step is $connection->setAdministrationId($id) — nothing else works until that's set.
Wrap the connection in a service. Moneybird is one of those APIs where "get a token" is a 3-step dance that happens once per administration, not once per user. A small singleton is worth it:
class MoneybirdService
{
public function forAdministration(string $moneybirdId): Moneybird
{
$account = MoneybirdAccount::where('administration_id', $moneybirdId)->firstOrFail();
$connection = new Connection();
$connection->setClientId(config('services.moneybird.client_id'));
$connection->setClientSecret(config('services.moneybird.client_secret'));
$connection->setRedirectUrl(config('services.moneybird.redirect'));
$connection->setAccessToken($account->access_token);
$connection->setAdministrationId($moneybirdId);
return new Moneybird($connection);
}
}
Store tokens per administration, not per user. A single user can link multiple administrations, and each grant has its own access token. Model this as moneybird_accounts(administration_id, access_token, refresh_token, expires_at, user_id) and key on the administration ID.
Refresh before every call. Moneybird access tokens expire — a setAccessToken() from yesterday silently 401s. Either:
if ($account->expires_at->isPast()) {
$connection->setRefreshToken($account->refresh_token);
$connection->refreshAccessToken();
$account->update([...]);
}
…or catch the exception, refresh, and retry. The retry approach is more robust to clock drift.
Reading and creating — same fluent API:
$moneybird = $this->forAdministration($id);
// Create a contact
$contact = $moneybird->contact();
$contact->fill([
'company_name' => 'Acme BV',
'firstname' => 'Jan',
'lastname' => 'Jansen',
'email' => '[email protected]',
'country' => 'NL',
]);
$contact->save(); // ID is written back to the model
echo $contact->id;
// Query with filters + pagination
$contacts = $moneybird->contact()
->filter(['query' => 'acme']) // supported per-entity
->page(1)
->perPage(100)
->get();
Building a sales invoice is where most projects spend their time. You need a tax_rate_id from the administration's own tax rate table, and line items are nested arrays, not entities:
$rates = $moneybird->taxRate()->get(); // cache per administration
$vat21 = collect($rates)->firstWhere('percentage', 21);
$product = $moneybird->product()->filter(['query' => 'SKU-1'])->get()->first();
$invoice = $moneybird->salesInvoice();
$invoice->fill([
'contact_id' => $contact->id,
'reference' => 'ORDER-1042',
'invoice_date' => now()->format('Y-m-d'),
'currency_id' => 'EUR',
'prices_are_incl_tax'=> false,
'details' => [[
'description' => 'Consultancy',
'amount' => '1',
'price' => '100.00',
'tax_rate_id' => $vat21->id,
'ledger_account_id' => $product->ledger_account_id,
]],
]);
$invoice->save();
Sending is a two-step async flow. save() creates a draft. Sending the invoice fires a background job on Moneybird's side; you cannot assume the PDF is ready when send() returns. Poll salesInvoice()->find($invoiceId) and check state === 'sent', or register a webhook and listen for invoice_state_changed.
Sync pattern — pull, diff, push. For dashboards, treat Moneybird as the source of truth for issued invoices but keep a local read model. Use ->filter(['updated_since' => $lastSync]) and store updated_at per entity. Do full reads, not incremental diffs, for contacts — Moneybird deduplicates softly and you will get dupes back if you delete locally.
Webhooks. Register them via $moneybird->webhook()->save() with a URL; verify the payload's signature server-side before trusting anything.
Moneybird instance without setAdministrationId() returns 404 on almost every entity, and the error message is not obvious. Make it impossible to construct a client without one.administration_id.fill() silently ignores unknown keys. If a field you "set" isn't in the entity's $fillable, nothing happens and no error is raised. When an invoice saves but a field is empty, check $fillable first.fill(). details for invoices (and custom_fields for contacts) are arrays of arrays; each line item must have its own tax_rate_id. A 400 with "Invalid tax rate" almost always means you pulled VAT IDs from a different administration's cache.save() mutates the model. After a successful save the entity has an id, and subsequent fill() + save() calls will update rather than insert. Keep a fresh instance per record in loops, or you'll overwrite the previous row.delete() requires a persisted entity. Calling it on an unsaved model returns false without throwing.RateLimiter or a small usleep; a naive contact loop on a 5k-customer administration will 429 on you.Y-m-d strings, never Carbon. Prices must be strings with 2 decimals ("100.00", not 100 or 100.0). Cast them before fill().sleep(). The PDF generation is not synchronous and there is no documented polling interval that's safe to rely on. Register invoice_state_changed and gate downstream actions on it.toArray() on an entity returns the last-known payload, not a fresh fetch. After a webhook fires, call ->find($id) if you need current state — the webhook body is often partial.Connection HTTP layer or point setBaseUrl() at a local mock server. Do this early — the OAuth dance alone makes integration testing expensive.Connection is stateful and not thread-safe. Don't reuse one across administrations in the same request; construct a new one per administration ID. It's cheap.Picqer\Financials\Moneybird\Model and set $namespace, $endpoint, and $fillable following the pattern of existing entities. You can then access it via $moneybird->getConnection()->... manually — the Moneybird facade won't know about it, but the underlying Connection::get() / post() work fine.ServiceProvider that binds MoneybirdService is ~20 lines and far easier to debug than a wrapper.How can I help you explore Laravel packages today?