madeitbelgium/teamleader
A Laravel/PHP integration for the Teamleader API that lets you query and manage CRM data such as contacts, companies, deals and invoices from your application through a simple client, so you can sync Teamleader records without writing raw HTTP calls yourself.
Install the SDK and publish its config in one shot:
composer require madeitbelgium/teamleader
php artisan vendor:publish --tag=teamleader
Add credentials to .env (register the app in the TeamLeader developer portal first — you need a client_id, client_secret, and a registered redirect URI):
TEAMLEADER_CLIENT_ID=
TEAMLEADER_CLIENT_SECRET=
TEAMLEADER_REDIRECT_URI="${APP_URL}/teamleader/callback"
The package is a thin Laravel wrapper over the TeamLeader Focus REST API, so the first thing to read is the published config/teamleader.php — it tells you which connector/base URL the service is bound to and how tokens are resolved. Then skim the package's src/ for the facades and request classes (or just composer show madeitbelgium/teamleader to see the autoloaded namespace). The README examples usually cover the auth dance and one listing call; treat them as the canonical "first use case."
Your first working endpoint should be a read-only listing — e.g. contacts or the current account — because it exercises auth end to end without any side effects. Build a tiny tinker session:
use MadeITBelgium\TeamLeader\Facades\TeamLeader;
TeamLeader::contacts()->list();
If that returns data, OAuth and configuration are correct and you can layer the write flows (deals, invoices, time tracking) on top.
Wrap the SDK in a domain service. Calls like TeamLeader::invoices()->create() get ugly inline. Put them behind app(Services\TeamLeader\InvoiceService::class) so controllers stay thin, retries/tests live in one place, and you have a seam to swap in a fake.
Treat the OAuth callback as a real flow, not a one-off. TeamLeader uses authorization-code OAuth2. Store access_token, refresh_token, and expires_at per user (or per tenant if you're multi-company), then refresh proactively before expiry — a 401 mid-sync is the most common production failure mode. A small TeamLeaderToken model with a refresh_token_if_expired() helper pays for itself immediately.
Queue anything that iterates. Bulk exporting contacts, syncing invoices, or reconciling time entries should all be dispatched jobs, not live request work. Combine a queued job per page with a cursor in the payload so a 429 doesn't lose progress.
Paginate explicitly. TeamLeader list endpoints return paged envelopes. Write one helper that accepts a closure and loops page/size until exhausted:
public function each(string $resource, callable $cb, int $size = 100): void {
for ($page = 1; ; $page++) {
$data = $this->client->{$resource}()->list(['page' => $page, 'size' => $size]);
if (empty($data->items())) return;
foreach ($data->items() as $item) { $cb($item); }
}
}
Map payloads to your own models rather than passing SDK response objects around. TeamLeader field names change between API versions and snake/camel casing is inconsistent — one fromTeamLeader(array $data): Contact mapper keeps the rest of the app insulated.
Test with HTTP fakes. Because the package is Saloon-based under the hood, you can usually stub the underlying Http client with Laravel's Http::fake() or Saloon's recording. Record one real fixture per endpoint and replay it in a test that asserts your DTO mapping — the SDK's own tests are your best source of expected payload shapes.
expires_at before every call, not once per process.size is capped server-side. Passing size=1000 doesn't error, it silently returns the max (usually 100). Trust the returned items count, not your requested size, as the loop terminator.YYYY-MM-DD strings and timestamps with timezone offsets. Don't new DateTime() and assume UTC — normalise with Carbon::parse()->tz('UTC') before storing, or you'll get off-by-one-day invoice dates.teamleader config key is global; the client is not. If you support multiple TeamLeader accounts (e.g. multi-tenant SaaS), don't rely on the singleton from the facade — instantiate the connector per account, or rebind the singleton on each request in a service provider / middleware.composer.json rather than ^ if you're shipping. The release cadence (latest 2025-05-16) means breaking API changes may arrive with little fanfare. Watch the TeamLeader API changelog yourself.$connector->getLastResponse(), or use Laravel's Http::fake() with a dd). The SDK's exceptions often wrap a generic message and hide the 400 body — the body is where the actual validation error lives.How can I help you explore Laravel packages today?