Search by

timatic / php-sdk

tomasvanrijsse

PHP SDK for Timatic API built with Saloon

1.0.0 2026-11-06 12:43 UTC

This package is auto-updated.

Last update: 2026-11-08 10:07:26 UTC


README

Tests codecov Code Style Static Analysis

A Laravel package for the Timatic API, built with Saloon and automatically generated from OpenAPI specifications.

Table of Contents

Requirements

  • PHP 8.2 or higher
  • Laravel 12.39 or higher

Installation

composer require timatic/php-sdk

Configuration

The package automatically registers itself via Laravel auto-discovery.

Publish the config file:

php artisan vendor:publish --tag=timatic-config

Add your API credentials to .env:

TIMATIC_BASE_URL=https://api.app.timatic.test
TIMATIC_API_TOKEN=your-api-token-here

Usage

Using Dependency Injection

The connector is registered in Laravel's service container, so you can inject it into controllers, commands and other classes:

use Timatic\DataTransferObjects\NewBudget;
use Timatic\Requests\Budget\BudgetsCollectionRequest;
use Timatic\Requests\Budget\BudgetsShowRequest;
use Timatic\Requests\Budget\BudgetsStoreRequest;
use Timatic\Requests\BudgetType\BudgetTypesCollectionRequest;
use Timatic\TimaticConnector;

class BudgetController extends Controller
{
    public function __construct(
        protected TimaticConnector $timatic
    ) {}

    public function index()
    {
        // One page of items, limited by the API's default page size
        $budgetTypes = $this->timatic
            ->send(new BudgetTypesCollectionRequest)
            ->dto();

        $defaultBudget = $this->timatic
            ->send(new BudgetsShowRequest(budgetId: 1337))
            ->dtoOrFail();

        // All items across all pages
        $budgets = $this->timatic
            ->paginate(new BudgetsCollectionRequest)
            ->dtoCollection();

        return view('budgets.index', compact('budgets', 'budgetTypes', 'defaultBudget'));
    }

    public function store(Request $request)
    {
        $budget = new NewBudget(
            title: $request->input('title'),
            totalPrice: $request->input('total_price'),
            customerId: $request->integer('customer_id'),
        );

        $created = $this->timatic
            ->send(new BudgetsStoreRequest($budget))
            ->dtoOrFail();

        return redirect()->route('budgets.show', $created->id);
    }
}

In Console Commands:

use Timatic\Requests\Budget\BudgetsCollectionRequest;
use Timatic\TimaticConnector;

class SyncBudgetsCommand extends Command
{
    public function handle(TimaticConnector $timatic): int
    {
        $budgets = $timatic->paginate(new BudgetsCollectionRequest)->dtoCollection();

        foreach ($budgets as $budget) {
            // Process budgets
        }

        return Command::SUCCESS;
    }
}

Read and write DTOs

Responses are hydrated into a read DTO per resource, such as Budget, Entry or User. Store and update requests take a separate write DTO, generated from the request body schema of that endpoint:

Request Body DTO
BudgetsStoreRequest NewBudget
BudgetsUpdateRequest UpdateBudget
EntriesStoreRequest NewEntry
EntriesUpdateRequest UpdateEntry

Write DTOs only contain the fields the API accepts, typed as the API expects them. For example, NewEntry has hasOvertime and an integer customerId, while the Entry read DTO has minutesSpent and a string customerId.

Testing

Every DTO has a factory, so you can build test data and fake API responses with Saloon's MockClient.

Here's an example of testing the BudgetController from above:

use Saloon\Http\Faking\MockClient;
use Saloon\Http\Faking\MockResponse;
use Saloon\Http\Request;
use Timatic\DataTransferObjects\Budget;
use Timatic\DataTransferObjects\BudgetType;
use Timatic\Foundation\JsonApi\JsonApiSerializer;
use Timatic\Requests\Budget\BudgetsCollectionRequest;
use Timatic\Requests\Budget\BudgetsShowRequest;
use Timatic\Requests\Budget\BudgetsStoreRequest;
use Timatic\Requests\BudgetType\BudgetTypesCollectionRequest;

test('it displays budgets and budget types', function () {
    $budget = Budget::testFactory()->state(['id' => '1'])->make();
    $budgetType = BudgetType::testFactory()->state(['id' => '1'])->make();

    MockClient::global([
        BudgetsCollectionRequest::class => MockResponse::make([
            'data' => [JsonApiSerializer::toJsonApi($budget)],
        ]),
        BudgetsShowRequest::class => MockResponse::make([
            'data' => JsonApiSerializer::toJsonApi($budget),
        ]),
        BudgetTypesCollectionRequest::class => MockResponse::make([
            'data' => [JsonApiSerializer::toJsonApi($budgetType)],
        ]),
    ]);

    $this->get(route('budgets.index'))
        ->assertOk()
        ->assertViewHas('budgets')
        ->assertViewHas('budgetTypes');
});

test('it creates a new budget', function () {
    $createdBudget = Budget::testFactory()->state(['id' => '2'])->make();

    $mockClient = MockClient::global([
        BudgetsStoreRequest::class => MockResponse::make([
            'data' => JsonApiSerializer::toJsonApi($createdBudget),
        ], 201),
    ]);

    $this->post(route('budgets.store'), [
        'title' => 'New Budget',
        'total_price' => 5000,
        'customer_id' => 123,
    ])->assertRedirect(route('budgets.show', '2'));

    $mockClient->assertSent(function (Request $request) {
        $attributes = $request->body()->all()['data']['attributes'];

        return $attributes['title'] === 'New Budget'
            && $attributes['totalPrice'] === 5000
            && $attributes['customerId'] === 123;
    });
});

Factory Methods

// A single DTO with random data, without an ID
$budget = Budget::testFactory()->make();

// Multiple DTOs with unique UUID IDs
$budgets = Budget::testFactory()->withId()->count(3)->make(); // Collection

// Override specific attributes
$budget = Budget::testFactory()->state([
    'title' => 'Q1 Budget',
    'totalPrice' => '10000.00',
])->make();

// Write DTOs have factories too
$entry = NewEntry::testFactory()->state(['hasOvertime' => false])->make();

For more on mocking, see the Saloon testing documentation.

Pagination

Every collection request supports pagination through Saloon's pagination plugin:

$paginator = $timatic->paginate(new BudgetsCollectionRequest);

// Optional; defaults to the API's page size
$paginator->setPerPageLimit(50);

// Iterate over all pages lazily
foreach ($paginator->items() as $budget) {
    // ...
}

// Or collect all DTOs at once
$allBudgets = $paginator->dtoCollection();

The paginator uses JSON:API pagination (page[number] and page[size]) and stops when links.next is empty.

Filtering and Includes

Collection requests that support it have filter() and typed include*() methods:

use Timatic\Foundation\Filtering\Operator;

$request = (new EntriesCollectionRequest)
    ->filter('userId', '42')
    ->filter('startedAt', '2026-01-01', Operator::GreaterThanOrEquals)
    ->includeBudget();

$entries = $timatic->paginate($request)->dtoCollection();

Response Methods

Responses are JsonApiResponse instances, which add JSON:API helpers to Saloon's Response:

$response = $timatic->send(new BudgetsCollectionRequest);

$firstBudget = $response->firstItem();

if ($response->hasErrors()) {
    $errors = $response->errors();
}

$meta = $response->meta();
$links = $response->links();
$included = $response->included();

HTTP Methods

The SDK has no PUT requests. It uses:

  • POST to create resources
  • PATCH to partially update resources
  • GET to retrieve resources
  • DELETE to remove resources

PUT is left out because Timatic never replaces a resource completely.

Available Resources

Budgets, budget types, corrections, customers, entries, entry suggestions, events, export formats, overtimes, Jira project mappings, suggestion integrations, teams, tickets, tracked domains and users. See src/Requests for every request.

JSON:API Support

DTOs are Spatie Data objects. The JSON:API envelope is flattened, so attributes are typed properties:

$budget->title;        // instead of $budget->attributes->title
$budget->budgetTypeId;
$budget->startedAt;    // Carbon instance

Relationships

Relationships are typed properties marked with #[JsonApiRelationship]. Included resources are hydrated into them:

$users = $timatic
    ->send((new UsersCollectionRequest)->includeTeam())
    ->dto();

$users->first()->team; // Team instance

When you send a DTO, JsonApiSerializer turns its relationships into resource identifiers:

use Timatic\DataTransferObjects\UpdateUser;

$team = $timatic->send(new TeamsShowRequest(teamId: '456'))->dtoOrFail();

$timatic->send(new UsersUpdateRequest(
    userId: '123',
    data: new UpdateUser(team: $team),
));

// Sends:
// [
//     'data' => [
//         'type' => 'users',
//         'attributes' => [],
//         'relationships' => [
//             'team' => ['data' => ['type' => 'teams', 'id' => '456']],
//         ],
//     ],
// ]

The JSON:API type comes from the #[JsonApiType] attribute on each DTO.

Regenerating the SDK

The SDK is generated from the Timatic OpenAPI spec by a companion LaravelZero app in generator/, which wraps timatic/jsonapi-sdk-generator.

First-time setup:

cd generator && composer install

Export the spec from a local timatic-backend checkout to openapi.json (gitignored) and regenerate:

(cd ../timatic-backend && docker compose exec -T app php artisan scramble:export --path=storage/api.json && mv storage/api.json ../timatic-php-sdk/openapi.json)
composer regenerate

composer regenerate rewrites src/DataTransferObjects, src/Requests, Factories and tests/Requests. To also rewrite src/Foundation, the connector, service provider and config, run the generator with --foundation --connector-name=TimaticConnector --base-url=https://api.tenant.timatic.app and revert the composer.json changes it makes (it normalizes the file and pins ergebnis/composer-normalize).

Development

Running Tests

# Run tests
composer test

# Run tests with code coverage
composer coverage

License

This package is licensed under the Elastic License 2.0 (ELv2).

Credits