Skip to content

Legacy Projects

MC0RE edited this page Aug 18, 2026 · 3 revisions

Legacy Projects

Manage legacy projects in Teamleader Focus β€” the original project system.

Overview

Legacy Projects is the original Teamleader project system. Unlike the current system, work is organised into milestones (phases) rather than groups, and each project must have at least one milestone and one participant at creation.

Access via Teamleader::legacyProjects().

Only available on accounts that have not yet migrated. Check with Teamleader::accounts()->isUsingLegacyProjects().

forCustomer() signature is (id, type) β€” id first, type second. This is the opposite of Projects::forCustomer().

Unknown filter keys and sort fields throw since v2.2.1. Both were previously passed through or dropped without comment.

No sideloading.

Which system, and what it's called

SDK method Teamleader::legacyProjects()
API path projects.* β€” the bare path belongs to the OLD system
Webhook events project.created, project.updated, project.deleted

The current system is Projects, on projects-v2/projects.* with nextgenProject.* events. So the class names and the endpoint paths run in opposite directions: this is the older class on the shorter path, while Projects is the newer class on the longer one. Teamleader chose the projects-v2 prefix for the new module specifically to avoid colliding with these existing endpoints.

Do not infer which system an account is on from whether a list call returns rows. Both endpoints answer. Ask directly:

Teamleader::accounts()->getProjectsVersion();      // 'projects-v2' or 'legacy'
Teamleader::accounts()->isUsingLegacyProjects();   // bool

Accounts are migrated over time; Accounts::getAutoSwitchDate() reports when, if a switch is scheduled. See Accounts.

Note there is no project.closed webhook event β€” the legacy system fires only created, updated and deleted. The current system adds nextgenProject.closed.

Endpoint

projects

Capabilities

Capability Supported
Pagination βœ… Supported
Filtering βœ… Supported
Sorting βœ… Supported (due_on, title, created_at)
Sideloading ❌ Not supported
Creation βœ… Supported
Update βœ… Supported
Deletion βœ… Supported

Methods

list(array $filters = [], array $options = [])

use McoreServices\TeamleaderSDK\Facades\Teamleader;

$projects = Teamleader::legacyProjects()->list();

$projects = Teamleader::legacyProjects()->list(
    ['status' => 'active'],
    ['sort' => [['field' => 'due_on', 'order' => 'asc']], 'page_size' => 50]
);

The customer filter can be passed either as a nested object or as flattened keys:

// Both work
$projects = Teamleader::legacyProjects()->list([
    'customer' => ['type' => 'company', 'id' => 'company-uuid'],
]);

$projects = Teamleader::legacyProjects()->list([
    'customer.type' => 'company',
    'customer.id'   => 'company-uuid',
]);

Anything else throws before the request is sent:

Teamleader::legacyProjects()->list(['project_id' => 'project-uuid']);
// InvalidArgumentException: Unsupported filter key for projects.list:
// project_id. Supported: customer, customer.type, customer.id, status,
// participant_id, term, updated_since.

info(string $id)

$project = Teamleader::legacyProjects()->info('project-uuid');

Takes no includes β€” this system has no sideloading.


create(array $data)

Four fields are required and validated:

Required Notes
title Project title
starts_on Start date (YYYY-MM-DD)
milestones Array β€” at least one milestone required
participants Array β€” at least one participant required
$project = Teamleader::legacyProjects()->create([
    'title'        => 'Office Renovation',
    'starts_on'    => '2025-05-01',
    'milestones'   => [
        [
            'name'                => 'Phase 1: Planning',
            'due_on'              => '2025-05-31',
            'responsible_user_id' => 'user-uuid',
            'billing_method'      => 'time_and_materials',
        ],
    ],
    'participants' => [
        ['type' => 'user', 'id' => 'user-uuid'],
    ],
    'customer'     => ['type' => 'company', 'id' => 'company-uuid'],
    'description'  => 'Full office renovation project',
]);

Teamleader requires at least one participant with the decision_maker role. The SDK checks that participants is a non-empty array but does not check roles, so a payload without a decision maker will be rejected server-side rather than client-side.


update(mixed $id, array $data)

Injects id into the request body.

Teamleader::legacyProjects()->update('project-uuid', ['title' => 'Office Renovation β€” Updated']);

delete(mixed $id)

Teamleader::legacyProjects()->delete('project-uuid');

close(string $id)

Closes the project and all its phases and tasks.

Teamleader::legacyProjects()->close('project-uuid');

Unlike Projects::close(), there is no closing strategy to choose β€” the cascade is unconditional.


reopen(string $id)

Teamleader::legacyProjects()->reopen('project-uuid');

Participant management

// Add β€” role defaults to 'member'
Teamleader::legacyProjects()->addParticipant(
    'project-uuid',
    ['type' => 'user', 'id' => 'user-uuid'],
    'decision_maker'
);

// Change an existing participant's role
Teamleader::legacyProjects()->updateParticipant(
    'project-uuid',
    ['type' => 'user', 'id' => 'user-uuid'],
    'member'
);

Roles: decision_maker, member.


Helper Methods

forCustomer(string $customerId, string $customerType = 'company')

⚠️ Id first, type second β€” opposite of Projects::forCustomer(), which is (type, id). Same method name, reversed arguments, so this is easy to get wrong when supporting both systems.

$projects = Teamleader::legacyProjects()->forCustomer('company-uuid');              // type defaults to 'company'
$projects = Teamleader::legacyProjects()->forCustomer('contact-uuid', 'contact');

Other helpers

Teamleader::legacyProjects()->active();                  // status: active
Teamleader::legacyProjects()->byStatus('on_hold');       // active | on_hold | done | cancelled
Teamleader::legacyProjects()->forParticipant('user-uuid');
Teamleader::legacyProjects()->search('renovation');
Teamleader::legacyProjects()->updatedSince('2025-01-01T00:00:00+02:00');

All accept an optional $options array for pagination and sorting.


Filters

Verified against @teamleader/focus-api-specification. This is the complete set.

Filter Type Description
customer object {type: contact|company, id: uuid} β€” singular object
status string active, on_hold, done, cancelled
participant_id string Filter by participant UUID
term string Search title or description
updated_since string ISO 8601 datetime

customer may be passed as a nested object or as the flattened customer.type / customer.id pair, so all three keys are accepted.

Unknown filter keys throw since v2.2.1. They were previously dropped silently β€” the API ignores keys it does not recognise and answers 200 with every project, so a mistyped key returned a plausible but unfiltered result.


Sorting

Field Description
due_on Project due date
title Project title
created_at Creation date

Pass either the sort option as an array of objects, or sort_field + sort_order:

$projects = Teamleader::legacyProjects()->list([], [
    'sort' => [['field' => 'due_on', 'order' => 'asc']],
]);

$projects = Teamleader::legacyProjects()->list([], [
    'sort_field' => 'title',
    'sort_order' => 'asc',
]);

A bare field name and a list of names are also accepted:

$projects = Teamleader::legacyProjects()->list([], ['sort' => 'due_on']);
$projects = Teamleader::legacyProjects()->list([], ['sort' => ['due_on', 'title']]);

Since v2.2.1 the field is validated against the three above. Anything else throws β€” previously it was passed straight to the API, which ignores unrecognised sort fields and returns records in its own order.


Differences from the current system

If your integration supports both, these are the things that differ beyond the endpoint:

Legacy Projects Projects (nextgen)
Status values active, on_hold, done, cancelled open, planned, running, overdue, over_budget, closed
Customer filter customer β€” singular object customers β€” plural array
forCustomer() (id, type) (type, id)
Webhooks project.* (no closed) nextgenProject.*
Organised into milestones, participants groups, tasks, materials
Sideloading none legacy_project, custom_fields
close() unconditional cascade takes a closing strategy
Required on create title, starts_on, milestones, participants title only

Branch once at the top rather than guessing:

if (Teamleader::accounts()->isUsingLegacyProjects()) {
    $projects = Teamleader::legacyProjects()->active();
} else {
    $projects = Teamleader::projects()->list(['status' => 'open']);
}

Response Notes

There is no meta block β€” the API returns pagination metadata only when a resource sends includes=pagination, and this one does not. There is no total count, so the end of a list is a page shorter than the requested page size.


Error Handling

use InvalidArgumentException;

// Missing required field
try {
    Teamleader::legacyProjects()->create(['title' => 'Test']); // missing starts_on, milestones, participants
} catch (InvalidArgumentException $e) {
    // "Field 'starts_on' is required for creating a project"
}

// Empty milestones array
try {
    Teamleader::legacyProjects()->create([
        'title' => 'Test', 'starts_on' => '2025-01-01',
        'milestones' => [], 'participants' => [['type' => 'user', 'id' => 'uuid']],
    ]);
} catch (InvalidArgumentException $e) {
    // 'At least one milestone is required'
}

// Empty participants array
try {
    Teamleader::legacyProjects()->create([
        'title' => 'Test', 'starts_on' => '2025-01-01',
        'milestones' => [['name' => 'Phase 1']], 'participants' => [],
    ]);
} catch (InvalidArgumentException $e) {
    // 'At least one participant is required'
}

// Unsupported filter key
try {
    Teamleader::legacyProjects()->list(['project_id' => 'project-uuid']);
} catch (InvalidArgumentException $e) {
    // 'Unsupported filter key for projects.list: project_id. Supported:
    //  customer, customer.type, customer.id, status, participant_id, term,
    //  updated_since.'
}

// Unsupported sort field
try {
    Teamleader::legacyProjects()->list([], ['sort' => 'starts_on']);
} catch (InvalidArgumentException $e) {
    // 'Invalid sort field: starts_on. projects.list accepts: due_on, title,
    //  created_at.'
}

// Wrong project system β€” the call succeeds and returns rows either way.
// There is no exception for this. Check the account first.

Related Resources

  • Accounts β€” getProjectsVersion() tells you which system an account uses
  • Projects β€” The current system, on projects-v2/projects.*
  • Legacy Milestones β€” Phases within a legacy project
  • Time Tracking β€” Filter legacy project time via relates_to with project
  • Webhooks β€” This resource fires project.* events
  • Files β€” Use files()->forLegacyProject(), which maps to subject type project

Clone this wiki locally