var-lab/lexoffice-bundle
A Laravel/PHP bundle that integrates the Lexoffice API, providing configuration and client services to connect your application with Lexoffice invoicing, contacts, and accounting workflows.
Know what you're installing first. This is a Symfony bundle, not a Laravel package. It registers a Lexoffice API client into the Symfony DI container. In Laravel you have two realistic options: (a) pull the underlying client library out of the bundle and wire it yourself, or (b) install symfony/http-kernel + the bundle and boot a mini container — which is usually more trouble than it's worth. For a Laravel codebase, read this bundle as reference implementation and config documentation for the SDK it wraps, then bind that SDK as a singleton in a service provider.
For a Symfony app:
composer require var-lab/lexoffice-bundle
config/bundles.php (Flex recipe may not exist — expect to do this by hand):
VarLab\LexofficeBundle\VarLabLexofficeBundle::class => ['all' => true],
office.lexoffice.de → Public API → key generation). The key is role-scoped — you must tick the roles you need (contacts, invoices, vouchers, articles, payments). A key missing the invoice role returns 403 on invoice calls and looks like an auth bug when it isn't.DependencyInjection/Configuration.php is the authoritative list of keys; typically:
# config/packages/lexoffice.yaml
lexoffice:
api_key: '%env(LEXOFFICE_API_KEY)%'
# base_uri / http client / logger options as exposed
bin/console debug:container --tag=lexoffice # or grep for the service id
Then hit a read-only endpoint (GET /profile) through the client. If profile works, auth and transport are fine and every later failure is payload-shaped, not infrastructure-shaped.First use case — the one that pays for itself: contact → draft invoice → finalize → PDF. Upsert a customer contact by email, create an invoice with finalize: false (a draft), inspect it in the Lexoffice UI, then finalize and pull the rendered PDF. That single flow exercises contact creation, tax handling, line items, the draft/finalize state machine, and binary document retrieval — i.e. every sharp edge you'll hit later.
Where to look first, in order: the bundle README examples → DependencyInjection/Configuration.php (what's configurable) → Resources/config/services.* (what the service IDs actually are) → composer.json of the bundle to see which HTTP client/SDK it depends on. If it wraps a third-party Lexoffice SDK, that SDK's README is your real API reference; the bundle is just glue.
Never call the client from a controller. Wrap it in a domain service — InvoiceIssuer, ContactSynchronizer — and inject that. The bundle's client is a transport object; treat it like PDO. This also gives you the seam to swap the package later without touching call sites.
Local record is the source of truth for identity. Every Lexoffice write returns an id. Persist it:
// after creating the invoice
$order->update([
'lexoffice_id' => $response['id'],
'lexoffice_version' => $response['version'],
'voucher_number' => $response['voucherNumber'],
'finalized_at' => $finalize ? now() : null,
]);
You will need version on every subsequent update — cache it alongside the ID rather than re-fetching.
Contacts: search-then-create, always. Lexoffice has no upsert. Look up by email (GET /contacts?email=...), create only on a miss, and store the returned contact ID. Duplicate contacts are painful to clean up and pollute the customer's bookkeeping. Put this behind a findOrCreateByEmail() method with a DB unique constraint on the email column to guard against concurrent jobs.
Draft-then-finalize. Create with finalize: false so a human (or a validation job) can review before the invoice is legally issued. Finalization flips state irreversibly — build the review step in from day one, not as a later refactor. The same pattern applies to quotations, credit notes, delivery notes, and order confirmations; they share the voucher shape.
Queue every write. The API is rate-limited to 2 requests/second per key. In a web request you'll be fine; in a nightly sync of 5,000 customers you will be throttled into 429s. Pattern: dispatch per-record jobs onto a dedicated queue with a single worker (or a rate-limiting middleware), and let the read side use a small concurrency cap. Backoff on 429 with jitter — never a fixed 1s retry.
Structured error handling. Lexoffice validation failures come back as 400 with an IssueList body, not a human sentence. Map it:
try {
$this->client->invoices()->create($payload);
} catch (ClientException $e) { // adapt to the bundle's exception type
$response = $e->getResponse();
if ($response->getStatusCode() === 400) {
$issues = json_decode($response->getBody(), true)['IssueList'] ?? [];
throw new InvoiceValidationException($issues, previous: $e);
}
throw $e;
}
Distinguish 401/403 (key or role problem — don't retry), 404 (missing resource / not-yet-generated document), 409 (version conflict), 429 (retry with backoff). Only the last two are retryable.
Webhooks over polling. POST /event-subscriptions registers a public HTTPS callback for invoice.created, invoice.status.*, etc. Use it to reconcile status instead of polling a list endpoint. Caveats: the endpoint must be publicly reachable (local dev needs a tunnel), delivery is at-least-once, so handler must be idempotent, and subscription creation is itself rate-limited.
PDF handling. The document endpoint returns application/pdf binary. Stream it to storage — don't hold it in memory or round-trip it through a JSON column:
Storage::put("invoices/{$order->id}.pdf", $client->invoices()->renderDocument($id));
Testing seam — the important one. Lexoffice has no official sandbox, so point the bundle's base URI at a local stub (WireMock / Http::fake() in Laravel) and assert on the exact JSON you send. Keep a tests/fixtures/lexoffice/*.json directory of real captured payloads (redacted). This is the single highest-leverage investment you can make with this package.
Symfony extension point. Decorate the bundle's service rather than editing it:
services:
App\Lexoffice\LoggingClient:
decorates: 'lexoffice.client' # confirm the real service id via debug:container
arguments: ['@.inner', '@logger']
Use that decorator to log request/response bodies (redact the API key), add correlation IDs, and inject the rate limiter — one place instead of fifty.
There is no sandbox environment. A real API key writes to real books and can issue real, legally-valid invoices. If you must test against production, use a clearly-prefixed test customer and never finalize. Otherwise: stub server only.
version is mandatory on updates. Every resource carries a version integer; updates that don't match the current server-side version fail with 409. Re-fetch and retry once, then surface loudly — silent retry loops on invoices are how you get duplicates.
API key roles are a hidden failure mode. 403 Unauthorized on exactly one resource usually means the key lacks that role, not that your code is wrong. Regenerating a key silently drops previously configured roles.
Money and rounding. JSON amounts are decimal numbers but the values come from a tax engine. Round only at the boundary into your own model, never accumulate floats across line items, and let Lexoffice compute totals where possible (taxType: net|gross|vatfree) rather than sending your own sums — mismatched totals produce opaque 400s.
Dates and timezone. yyyy-MM-dd for dates, ISO-8601 for datetimes, Europe/Berlin assumed. A UTC created_at cast straight into a payload will produce off-by-one-day voucher dates around midnight — and voucher dates are legal documents.
PDF isn't instant. GET /invoices/{id}/document can 404/406 immediately after finalization while rendering completes. Retry with backoff rather than treating the first miss as fatal.
The abandonment risk is real. Zero stars, a handful of dependents, a small vendor's own Git host. Pin an exact version, vendor or fork it, and keep it behind your own interface so the swap to raw Guzzle is a day's work, not a rewrite. Read the composer.json constraints against your Symfony/PHP version before planning anything — an unmaintained bundle pinned to an old symfony/http-client can block your framework upgrades.
Debugging. In Symfony, enable the HTTP client profiler panel — the bundle is almost certainly built on symfony/http-client, so every request/response appears there with headers and body. Add a dedicated lexoffice Monolog channel; without it you'll hunt through your app log for a single 400 body.
Config quirks. The bundle may ship no Flex recipe, so config/bundles.php and the YAML file are yours to write. The env var must resolve at container compile time in prod (don't read env() at runtime in Symfony, and in Laravel never call config() from inside a config file). If you see You have requested a non-existent parameter "lexoffice.api_key", the bundle isn't registered yet — the config is silently ignored rather than erroring.
Extend at the serializer, not the transport. When the bundle's DTOs inevitably lag new API fields, decorate the serializer/mapper or send raw arrays, rather than forking the bundle for one missing property. Pin the fork point as far from the HTTP layer as you can.
How can I help you explore Laravel packages today?