Search by

mcore-services / teamleader-sdk

MC0RE

Laravel SDK for Teamleader Focus API

v3.4.2 2026-10-20 22:13 UTC

README

Latest Version Total Downloads PHP Version Laravel Version License Tests Docs Teamleader API spec

A Laravel package for the Teamleader Focus API. Handles OAuth, encrypted token storage and renewal, rate limiting, and around 70 API resources behind a consistent interface β€” for one Teamleader account or many.

Quick Links:

✨ Key Features

πŸ” Authentication & Security

  • Complete OAuth 2.0 Flow β€” Teamleader::authorize() and one callback route; the state is generated and checked for you
  • Encrypted Token Storage β€” tokens encrypted with APP_KEY in the database and the cache
  • Scheduled Token Renewal β€” tokens are renewed every ten minutes before they expire, so an idle connection stays connected
  • Right-Account Check β€” expected_account_id refuses a callback that connects the wrong Teamleader account
  • Concurrent Request Safety β€” a per-connection lock prevents token refresh race conditions

🏒 Multiple Accounts

  • Connections β€” one application, several Teamleader accounts, each with its own credentials, tokens and rate-limit window
  • Stored in config or the database β€” add an account with php artisan teamleader:connections:add, no deploy needed

πŸš€ Performance & Reliability

  • Proactive Rate Limiting β€” Redis-backed sliding window that waits for a free slot rather than letting you hit the 200 req/min limit
  • Retry Logic β€” Automatic retry with backoff for transient failures
  • Lazy Pagination β€” lazy() and cursor() page through any list with flat memory use

πŸ“¦ Bulk & Command Line

  • Bulk Export β€” any list to CSV or JSON Lines, safe against spreadsheet formula injection
  • Bulk Writes β€” create, update, delete or call any method for many rows: validated first, resumable, with a dry run, in-process or on the queue
  • Artisan CLI β€” explore, list, export, import and call the API from the terminal; nothing writes without --write

🧰 Developer Experience

  • Resource-Based Architecture β€” Organised access to all API endpoints
  • Fluent Sideloading β€” Reduce API calls by including related resources
  • Fail-Fast Validation β€” Unsupported filters, sort fields and includes throw before the request is sent, rather than being silently ignored by the API
  • Typed Exceptions by Default β€” a failed request throws an exception with an actionable message
  • Events β€” for every request, response, failure, rate-limit wait and token refresh, plus optional request logging
  • Laravel Pulse Cards β€” Teamleader traffic, failures, rate-limit waits and connections that need reconnecting on your Pulse dashboard, plus which routes and jobs spend their time on Teamleader
  • Test Fake β€” Teamleader::fake() records requests instead of sending them, with assertions per endpoint and connection
  • Resource Introspection β€” Query any resource's capabilities programmatically
  • AI Agent Guidelines β€” ships Laravel Boost guidelines, so Claude Code, Cursor and Copilot use the SDK correctly

🎯 API Coverage

CRM β€” Companies, Contacts, Business Types, Tags, Addresses

Deals & Sales β€” Deals, Quotations, Orders, Pipelines, Phases, Sources, Lost Reasons

Invoicing β€” Invoices, Credit Notes, Payment Methods, Payment Terms, Tax Rates, Withholding Tax Rates, Commercial Discounts, Subscriptions

Expenses β€” Expenses, Incoming Invoices, Incoming Credit Notes, Receipts, Bookkeeping Submissions

Projects & Time Tracking β€” Projects (both the current and legacy systems), Project Tasks, Groups, Materials, Project Lines, External Parties, Time Tracking, Timers

Planning β€” Reservations, User Availability, Plannable Items

Calendar & Activities β€” Meetings, Calls, Call Outcomes, Calendar Events, Activity Types

Products & Services β€” Products, Product Categories, Units of Measure, Work Types, Price Lists

General β€” Users, Teams, Departments, Custom Fields, Currencies, Notes, Files, Document Templates, User Schedules, Closing Days, Days Off, Day Off Types, Email Tracking

System β€” Webhooks, Cloud Platforms, Accounts, Migration Utilities

πŸ“‹ Requirements

  • PHP: 8.4 or higher (tested on 8.4 – 8.5)
  • Laravel: 12.x or 13.x
  • Extensions: ext-json, ext-mbstring
  • Database: MySQL 5.7+, PostgreSQL 10+, or SQLite 3.8+
  • Redis: required for rate limiting, which is enabled by default

Laravel 10 and 11 were dropped in v2.0. Both are EOL with unpatched CVEs, and Composer's security advisories block installing them.

πŸš€ Installation

1. Install via Composer

composer require mcore-services/teamleader-sdk

2. Publish the Configuration and Run the Migrations

php artisan vendor:publish --tag=teamleader-config
php artisan migrate

3. Configure Environment Variables

TEAMLEADER_CLIENT_ID=your_client_id
TEAMLEADER_CLIENT_SECRET=your_client_secret
TEAMLEADER_REDIRECT_URI="${APP_URL}/teamleader/callback"

The redirect URI must match the one registered on your integration in the Teamleader developer portal exactly β€” http versus https included.

4. Set Up OAuth Routes

// routes/web.php
use Illuminate\Http\Request;
use McoreServices\TeamleaderSDK\Facades\Teamleader;

Route::get('/teamleader/connect', function () {
    return Teamleader::authorize();
});

Route::get('/teamleader/callback', function (Request $request) {
    if (Teamleader::handleCallback($request->query('code'), $request->query('state'))) {
        return response('Connected to Teamleader. You can close this tab and run: php artisan teamleader:status');
    }

    return response('Connecting to Teamleader failed. Check storage/logs/laravel.log.', 500);
});

Once others can reach your application, put both routes behind your login (->middleware('auth')): anyone who can open /teamleader/connect can connect a Teamleader account to it.

5. Run the Scheduler

The package schedules teamleader:tokens:refresh every ten minutes. Run php artisan schedule:work locally, and a cron entry for php artisan schedule:run in production (Laravel Forge: Scheduler).

Upgrading from 2.x? See the upgrade guide.

πŸ”‘ Authentication

use McoreServices\TeamleaderSDK\Facades\Teamleader;

// 1. Redirect the user to Teamleader β€” a state is generated and remembered
return Teamleader::authorize();

// 2. Handle the callback β€” the state is checked, tokens are encrypted and stored
Teamleader::handleCallback($code, $state);

// 3. Check authentication status
if (Teamleader::isAuthenticated()) {
    // Ready to make API calls
}
php artisan teamleader:status --all   # every connection, its account and token expiry

When Teamleader refuses a refresh token, the connection is marked needs_reauthorization and its requests throw ConnectionNeedsReauthorizationException until it is connected again.

πŸ“– Basic Usage

Every resource follows the same shape:

$resource->list(array $filters = [], array $options = []);
$resource->info(string $id);
$resource->create(array $data);
$resource->update(string $id, array $data);
$resource->delete(string $id);

$filters maps to the API's filter object. $options carries page_size, page_number, sort, sort_order and include.

Teamleader dates every record at the moment it is created: a deal, note, e-mail or file cannot be backdated through the API. When you migrate data, keep original dates in the content or in a custom field.

Companies

use McoreServices\TeamleaderSDK\Facades\Teamleader;

// List with filters, pagination and sideloading
$companies = Teamleader::companies()->list(
    ['status' => 'active'],
    ['page_size' => 50, 'sort' => 'name', 'include' => 'custom_fields']
);

// Get a single company
$company = Teamleader::companies()->info('company-uuid');

// Create
$company = Teamleader::companies()->create([
    'name'                    => 'Acme Corp',
    'vat_number'              => 'BE0123456789',
    'emails'                  => [['type' => 'primary', 'email' => 'info@acme.be']],
    'marketing_mails_consent' => true,
]);

// Upload a logo β€” base64 data URI, or null to remove
$logo = 'data:image/png;base64,'.base64_encode(file_get_contents('/path/to/logo.png'));
Teamleader::companies()->uploadLogo('company-uuid', $logo);

Contacts

$contacts = Teamleader::contacts()->list(
    ['marketing_mails_consent' => true],
    ['include' => 'custom_fields']
);

// Link to a price list β€” or pass null to remove it
Teamleader::contacts()->update('contact-uuid', ['price_list_id' => 'price-list-uuid']);

$avatar = 'data:image/jpeg;base64,'.base64_encode(file_get_contents('/path/to/avatar.jpg'));
Teamleader::contacts()->uploadAvatar('contact-uuid', $avatar);

Deals

$deals = Teamleader::deals()->list(
    ['status' => ['open']],
    ['sort' => 'weighted_value', 'sort_order' => 'desc']
);

$deal = Teamleader::deals()->create([
    'title'           => 'New Business Deal',
    'lead'            => ['customer' => ['type' => 'company', 'id' => 'company-uuid']],
    'estimated_value' => ['amount' => 10000, 'currency' => 'EUR'],
]);

Teamleader::deals()->win($deal['data']['id']);

Invoices

// create() drafts an invoice. listDrafts() lists existing drafts.
$invoice = Teamleader::invoices()->create([
    'department_id' => 'dept-uuid',
    'invoicee'      => ['customer' => ['type' => 'company', 'id' => 'company-uuid']],
    'payment_term'  => ['type' => 'after_invoice_date', 'days' => 30],
    'grouped_lines' => [[
        'line_items' => [[
            'quantity'    => 5,
            'description' => 'Consulting',
            'unit_price'  => ['amount' => 150.0, 'tax' => 'excluding'],
            'tax_rate_id' => 'tax-rate-uuid',
        ]],
    ]],
]);

$outstanding = Teamleader::invoices()->list(['status' => ['outstanding']]);

A grouped line with no section title must omit the section key entirely β€” Teamleader rejects both null and ''. See the Invoices reference.

Time Tracking

Teamleader::timeTracking()->create([
    'started_at'   => now()->toIso8601String(),
    'duration'     => 3600,
    'subject'      => ['type' => 'ticket', 'id' => 'ticket-uuid'],
    'work_type_id' => 'work-type-uuid',
]);

$entries = Teamleader::timeTracking()->betweenDates(
    '2026-08-01T00:00:00+02:00',
    '2026-08-31T23:59:59+02:00'
);

Files

// Requests the upload link and sends the file to it
Teamleader::files()->uploadFile(storage_path('contract.pdf'), 'deal', 'deal-uuid', 'Contracts');

$files = Teamleader::files()->forDeal('deal-uuid');
$offers = Teamleader::files()->forDeal('deal-uuid', ['filters' => ['term' => 'offerte']]);
$some = Teamleader::files()->byIds(['file-uuid-1', 'file-uuid-2']);

// Downloads: the bytes, or straight onto a Laravel disk
$pdf = Teamleader::invoices()->downloadContents('invoice-uuid');
Teamleader::invoices()->downloadTo('invoice-uuid', 's3', 'invoices/2026-0042.pdf');

The extension must be a type Teamleader knows (Files::MIME_TYPES: pdf, docx, xlsx, jpg, png and so on); others are refused before anything is sent. upload() and download() on their own only return a temporary link. downloadContents() and downloadTo() work on invoices, credit notes, quotations and files.

Custom Fields

$field = Teamleader::customFields()->create([
    'label'   => 'VAT Number',
    'type'    => 'single_line',
    'context' => 'company',
]);

// Every definition, paging handled for you
$all = Teamleader::customFields()->all();

// A select field takes the option label, not the option id
$value = Teamleader::customFields()->selectValue('field-uuid', 'option-uuid-or-label');

πŸ“„ Pagination

lazy() pages through any list for you and returns a LazyCollection: pages of 100 are fetched only as you consume them.

Teamleader::companies()
    ->lazy(['status' => 'active'])
    ->each(function (array $company) {
        // one record at a time
    });

Teamleader::deals()->lazy(['status' => 'open'])->take(10)->all();   // one request

The API returns no total count on most endpoints, so the end of a list is a page shorter than the page size. cursor() gives you the pager itself β€” total() where the endpoint reports one, and lastPage() to resume an interrupted export.

⚑ Rate Limiting

Teamleader allows 200 requests per sliding minute. The SDK tracks usage in Redis, throttles progressively as the window fills, and waits for a free slot rather than firing a request that can only come back as a 429.

$stats = Teamleader::getRateLimitStats();
echo "Remaining: {$stats['remaining']} / {$stats['rate_limit']}";

The wait is capped by teamleader.rate_limiting.max_wait_ms (default 5000). Once the cap is reached, a RateLimitExceededException is thrown and the decision is yours. In a queue worker, that usually means releasing the job:

use McoreServices\TeamleaderSDK\Exceptions\RateLimitExceededException;

try {
    Teamleader::deals()->list();
} catch (RateLimitExceededException $e) {
    $this->release($e->getRetryAfter());
}

Set max_wait_ms to 65000 to have the SDK wait out a full window itself. That suits a CLI import, but not a web request.

πŸ”— Sideloading

Related records can be requested in the same call, via $options['include'] or the fluent methods:

$deals = Teamleader::deals()->list(
    ['status' => ['open']],
    ['include' => 'custom_fields']
);

$company = Teamleader::companies()
    ->withRelatedContacts()
    ->info('company-uuid');

// What does this resource actually accept?
$capabilities = Teamleader::companies()->getCapabilities();

Includes are per endpoint. list and info frequently accept different sets, and some endpoints accept none at all. An unsupported include throws rather than being silently ignored.

🏒 Multiple Connections

// config/teamleader.php
'connections' => [
    'antwerp' => [
        'client_id' => env('TEAMLEADER_ANTWERP_CLIENT_ID'),
        'client_secret' => env('TEAMLEADER_ANTWERP_CLIENT_SECRET'),
    ],
],
Teamleader::connection('antwerp')->companies()->list();

Or store a connection in the database, without a deploy:

php artisan teamleader:connections:add antwerp

Each connection has its own integration, tokens, refresh lock and rate-limit window; one callback route serves them all.

πŸ“¦ Bulk Operations

// Any list to a file
Teamleader::bulk()
    ->export('contacts', ['tags' => ['customer']])
    ->toCsv(storage_path('contacts.csv'), ['id', 'first_name', 'last_name', 'emails.0.email']);

// Many writes: every row validated before the first is sent
$result = Teamleader::bulk()
    ->create('companies', $rows)
    ->continueOnError()
    ->run();

$result->succeeded();   // [row key => API response]
$result->failed();      // [row key => BulkFailure]

// Or on the queue, in chunks
Teamleader::bulk()->update('deals', $rows)->dispatch(chunk: 50);

dryRun() shows exactly what would be sent, and resumeFrom($result) skips the rows that already succeeded. call('deals', 'win', $rows) runs any method once per row, and call('files', 'uploadFile', $rows) uploads local files.

dispatch() needs a real queue; Horizon only works the redis connection. The 200 requests a minute are per Teamleader account, so one or two worker processes are enough.

πŸ’» Command Line

php artisan teamleader:describe deals
php artisan teamleader:list deals --filter='status[]=open' --sort=created_at:desc --fields=id,title
php artisan teamleader:export contacts --filter='tags[]=customer' --output=storage/customers.csv
php artisan teamleader:import companies companies.csv --dry-run
php artisan teamleader:call users.me

Every command validates filters, sort fields and includes the same way your code does, and takes --connection=. Nothing writes without --write, and in production --force is required as well.

πŸ§ͺ Testing

Teamleader::fake([
    'deals.create' => ['data' => ['id' => 'deal-1', 'type' => 'deal']],
    'deals.info'   => Teamleader::response()->status(404),
]);

// … your code …

Teamleader::assertSent('deals.create', fn (array $body) => $body['title'] === 'Big deal');

No account, token or network needed, and the resources still validate what they build. See Testing your integration.

πŸ€– AI Agents

The package ships guidelines for Laravel Boost. With Boost installed, your coding agent picks them up:

composer require laravel/boost --dev
php artisan boost:install    # or boost:update, after upgrading the SDK

They teach the agent to go through the facade, to look up filters and fields instead of guessing them, and the API quirks that cost time to find out.

Not using Boost? Point any agent at llms-full.txt: the complete documentation as one file.

πŸ“‘ Events

use Illuminate\Support\Facades\Event;
use McoreServices\TeamleaderSDK\Events\TokenRefreshFailed;

Event::listen(function (TokenRefreshFailed $event) {
    if ($event->reauthorizationRequired) {
        // tell someone to reconnect $event->connection
    }
});

RequestSending, ResponseReceived, RequestFailed, RateLimitWaited, TokenRefreshed, TokenRefreshFailed, ConnectionAuthorized and BulkBatchFinished are fired only when something listens. Set TEAMLEADER_LOG_REQUESTS=true to log every request to a channel of your choice.

πŸ“ˆ Monitoring with Pulse

With Laravel Pulse installed, add the SDK's recorders to config/pulse.php:

'recorders' => [
    \McoreServices\TeamleaderSDK\Pulse\Recorders\TeamleaderRequests::class => [
        'enabled' => env('PULSE_TEAMLEADER_REQUESTS_ENABLED', true),
        'sample_rate' => env('PULSE_TEAMLEADER_REQUESTS_SAMPLE_RATE', 1),
        'threshold' => env('PULSE_TEAMLEADER_REQUESTS_THRESHOLD', 1000),
        'ignore' => [],
        'connections' => ['ignore' => [], 'groups' => []],
    ],
    \McoreServices\TeamleaderSDK\Pulse\Recorders\TeamleaderCallers::class => [
        'enabled' => env('PULSE_TEAMLEADER_CALLERS_ENABLED', true),
        'sample_rate' => env('PULSE_TEAMLEADER_CALLERS_SAMPLE_RATE', 1),
        'ignore' => [],
    ],
    // …
],

and the cards to your dashboard (php artisan vendor:publish --tag=pulse-dashboard):

<livewire:teamleader.requests cols="8" />
<livewire:teamleader.connections cols="4" />
<livewire:teamleader.callers cols="full" />

Requests and failures per connection and endpoint, rate-limit waits, token renewals, the connections that need reconnecting, and the share of each route's, job's and command's time spent on Teamleader. Bodies are never recorded. See Monitoring with Pulse.

πŸͺ Webhooks

Teamleader::webhooks()->register('https://your-app.com/webhooks/teamleader', [
    'invoice.booked',
    'invoice.peppolSubmissionSucceeded',
    'invoice.peppolSubmissionFailed',
    'deal.won',
]);

// Or a whole category at once
$types = Teamleader::webhooks()->getInvoiceEventTypes();
Teamleader::webhooks()->register('https://your-app.com/webhooks/teamleader', $types);

The payload carries the entity id at subject.id, not data.id:

Route::post('/webhooks/teamleader', function (Request $request) {
    ProcessTeamleaderEvent::dispatch(
        $request->input('type'),
        $request->input('subject.id')
    );

    return response()->json(['status' => 'received']);
});

πŸ› οΈ Error Handling

Since v3.0, a failed request throws a typed exception by default. Set TEAMLEADER_THROW_EXCEPTIONS=false to get the 2.x behaviour back: an array with error => true. All API exceptions extend TeamleaderException.

use McoreServices\TeamleaderSDK\Exceptions\AuthenticationException;
use McoreServices\TeamleaderSDK\Exceptions\NotFoundException;
use McoreServices\TeamleaderSDK\Exceptions\RateLimitExceededException;
use McoreServices\TeamleaderSDK\Exceptions\TeamleaderException;
use McoreServices\TeamleaderSDK\Exceptions\ValidationException;

try {
    $company = Teamleader::companies()->info('company-uuid');
} catch (NotFoundException $e) {
    // 404 β€” the record does not exist
} catch (ValidationException $e) {
    // 422 β€” the API rejected the payload
} catch (RateLimitExceededException $e) {
    // 429 β€” always thrown, never swallowed
    $this->release($e->getRetryAfter());
} catch (AuthenticationException $e) {
    // Includes ConnectionNeedsReauthorizationException β€” connect again
    return redirect('/teamleader/connect');
} catch (TeamleaderException $e) {
    logger()->error('Teamleader API error', ['message' => $e->getMessage()]);
}

Unsupported filter keys, sort fields, includes and subject types throw an InvalidArgumentException before the request is sent:

Teamleader::timeTracking()->list(['updated_since' => '2026-08-01T00:00:00+02:00']);
// InvalidArgumentException: Unsupported filter key for timeTracking.list:
// updated_since. Supported: ids, user_id, started_after, ...

This is deliberate. The API answers 200 to filters it does not recognise and returns the complete unfiltered set, so silence is the more dangerous outcome.

πŸ–₯️ Artisan Commands

php artisan teamleader:status --all          # Every connection: account, status, token expiry
php artisan teamleader:health                # Health check
php artisan teamleader:config:validate       # Validate configuration
php artisan teamleader:tokens:refresh        # Renew tokens that expire soon (scheduled)
php artisan teamleader:connections:add       # Store a connection in the database
php artisan teamleader:connections:list
php artisan teamleader:connections:remove
php artisan teamleader:connections:rename    # Keeps credentials and tokens
php artisan teamleader:connections:expect    # Pin the account a connection must connect to
php artisan teamleader:resources             # Every resource and what it supports
php artisan teamleader:export-uuids          # Export reference UUIDs

Plus describe, list, info, export, import and call β€” see Command Line.

βœ… Specification Parity

Every resource is checked against Teamleader's machine-readable API specification, @teamleader/focus-api-specification, currently pinned at 1.229.0.

The Teamleader API answers 200 OK to a filter, sort field, include or body field it does not recognise, and simply ignores it. A typo therefore looks like success: a mistyped filter returns every record, and a mistyped field on an update changes nothing. The SDK checks those names against the specification and throws before the request is sent.

How that is kept true:

  • Spec contract tests. Field lists, required fields and enums on each resource are asserted against a fixture generated from the specification.
  • Spec audit. composer spec:audit compares every resource's filters, sort fields, includes, pagination and endpoints with the specification; php bin/spec-audit --check fails CI on anything not recorded in the baseline. Two divergences are recorded: users.getWeekSchedule, which Teamleader deprecated and the SDK deliberately does not wrap, and dealPhases.duplicate, which Teamleader removed from the specification and the SDK keeps, deprecated, until v4.0.
  • Weekly watch. A scheduled workflow audits the SDK against the newest published specification and opens an issue when it finds a difference, so API changes surface before they surface as bug reports.
composer spec:audit -- --summary     # counts per category
composer spec:audit -- --new         # anything not in the baseline
composer spec:check                  # the CI gate

🀝 Contributing

Contributions are welcome β€” see CONTRIBUTING.md.

Bug reports are especially useful when they include what you expected, what happened, and how you worked around it. Several fixes in v2.2.0 came directly from reports written that way.

πŸ”’ Security

If you discover a security issue, email help@mcore-services.be rather than using the issue tracker. See SECURITY.md.

πŸ“ Changelog

See CHANGELOG.md.

πŸ“œ License

The MIT License (MIT). See LICENSE.md.

πŸ™ Credits

πŸ’¬ Support

πŸ—ΊοΈ Roadmap

  • Spec-derived filter parity across every resource, enforced by a test (v2.3.0)
  • Payload tests for all resources (v2.3.0)
  • Multiple connections, encrypted token storage and scheduled renewal (v3.0)
  • Bulk operations: export, writes and queued bulk (v3.0)
  • CLI for exploring and querying the API (v3.0)
  • Events and request logging (v3.0)
  • Test fake and document downloads (v3.1)
  • Connection management: rename, expected account, account ids (v3.2)
  • Laravel Boost guidelines for AI coding agents (v3.3)
  • Laravel Pulse recorders and cards (v3.4)
  • Webhook receiver: typed events per connection, duplicate protection, webhooks:sync (v3.5)
  • Upsert helper for bulk writes, with id mapping (v3.5)
  • Incremental sync and reconciliation (v3.6)
  • Cached lookups for reference data (v3.6)

Made with ❀️ by MCore Services