nxi / factro-sdk
Unofficial PHP SDK for the factro Core API, built on Symfony components
Requires
- php: ^8.4
- psr/log: ^3.0
- symfony/clock: ^7.4 || ^8.0
- symfony/http-client: ^7.4 || ^8.0
- symfony/http-client-contracts: ^3.5
- symfony/mime: ^7.4 || ^8.0
- symfony/rate-limiter: ^7.4 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2.2
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.2
- rector/rector: ^2.5
- symfony/cache: ^7.4 || ^8.0
Suggests
- symfony/cache: CacheStorage for the rate limiter when several PHP processes share one token
- symfony/lock: LockFactory for the rate limiter when several hosts share one token
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-13 07:55:50 UTC
README
Unofficial SDK. Not affiliated with, endorsed by or supported by Schuchert Managementberatung GmbH & Co. KG, the maker of factro. factro® is a registered trademark of Schuchert Managementberatung GmbH & Co. KG.
factro is a web-based project management software made in Germany. Its Core API is a REST API for projects, packages, tasks, users, work records and the other objects of a factro tenant; the official documentation is at https://cloud.factro.com/api/core/docs/.
This package is a PHP SDK for the factro Core API, built exclusively on Symfony components (symfony/http-client, symfony/rate-limiter, symfony/clock, symfony/mime). It encapsulates the API's quirks (raw token header, IETF rate-limit headers, mixed date formats, partial-object PUT semantics) behind typed Api classes, readonly DTOs and a small exception family.
Requirements
- PHP 8.4 or newer
- Symfony 7.4 LTS or 8 components:
symfony/http-client,symfony/rate-limiter,symfony/clock psr/log3- a token for the factro Core API
Installation
composer require nxi/factro-sdk:^1.0
Usage
use Nxi\Factro\FactroClientFactory; use Nxi\Factro\FactroOptions; use Nxi\Factro\Policy\RequestPolicy; use Nxi\Factro\Resource\Task\Input\NewTask; use Nxi\Factro\Resource\Task\Input\TaskChanges; use Nxi\Factro\Resource\Task\TaskState; use Nxi\Factro\Time\CalendarDate; $client = FactroClientFactory::create( token: $token, // raw factro Core API token, sent without "Bearer" options: new FactroOptions( policy: RequestPolicy::withoutDeletes(), logger: $logger, limiterFactory: $limiterFactory, // optional, default: in-memory sliding window 480/min ), httpClient: $baseClient, // optional, default: HttpClient::create() ); $task = $client->tasks()->get('c1c9...'); $open = array_filter($client->tasks()->listByProject($projectId), fn ($t) => $t->taskState->isOpen()); $created = $client->tasks()->create(new NewTask( title: 'Review offer', targetParentId: $packageId, endDate: CalendarDate::fromYmd('2026-09-30'), )); $client->tasks()->update($created->id, new TaskChanges(description: '<p>Done</p>')); $client->tasks()->setState($created->id, TaskState::CLOSED);
Update DTOs send only the fields that are set: factro treats PUT as a partial update, so null means "leave unchanged". To empty a field, name its payload key in clear; it is sent as explicit null:
$client->tasks()->update($taskId, new TaskChanges(clear: ['executorId']));
clear exists in TaskChanges, ProjectChanges, PackageChanges, ChecklistEntryChanges and NewTask (there it suppresses factro's default, e.g. a task without executor). factro does not accept null for every field (officerId: null on a task is refused with 403), so each of these classes lists the keys verified against the API in its CLEARABLE constant. The constructor throws \InvalidArgumentException for a key outside CLEARABLE or a key that is also set in the same call.
FactroClientFactory::create() wraps the base client in a decorator chain: request policy, retry (GET only), rate limiter, scoping (base URI, headers, timeouts). Every request passes through Transport, which decodes JSON, maps failures onto the exception family in Nxi\Factro\Exception and logs one line per call (never headers, never bodies). Resource classes are reached through $client->projects(), packages(), tasks(), users(), companies(), contacts(), workRecords(), appointments(), teams(), documents(), comments(), notes(), todoLists(), webhooks(), customViews() and templates(); together they cover every operation of the Core API except GET /tasks (tenant-wide, unpaginated) and the deprecated /efforts* routes.
Package layout
Classes are grouped by API resource under Nxi\Factro\Resource. Each resource namespace holds the resource class (reached through $client->tasks() and friends) and its enums; what the SDK sends lives in Input, what it receives in Output:
Nxi\Factro\Resource\Task\Tasks
Nxi\Factro\Resource\Task\TaskState, TaskPriority, Urgency
Nxi\Factro\Resource\Task\Input\NewTask, TaskChanges, NewTaskComment, TaskConnectionInput
Nxi\Factro\Resource\Task\Output\Task, TaskComment, TaskConnection
Project, Package, WorkRecord, User, Company, Contact, Appointment, Team, Document, Comment, Note, TodoList, Webhook, CustomView and Template follow the same pattern. Enum cases are UPPERCASE (TaskState::IN_PROCESS, TaskPriority::PRIORITY_50). Enum properties of output DTOs are nullable and have a raw sibling ($task->taskState and $task->taskStateValue): a value this SDK version does not know yet hydrates as null with the raw value kept, instead of failing the whole response. Nxi\Factro\Http, Nxi\Factro\Mapping, Resource\AbstractResource and the toPayload() methods of the input DTOs are @internal.
Operations that recur across resources share one naming scheme:
| Operation | Methods |
|---|---|
| CRUD | list(), get(), find() (null on 404), create(), update(), delete() |
Batch routes (POST /x/x, PUT /x/x) |
createMany(list<NewX>), updateMany(array<id, XChanges>) |
| Comments | comments(), comment(), addComment(), deleteComment() |
| Tags | taskTags()/projectTags()/companyTags()/employeeTags(), createXTag(), deleteXTag(), tags($id), addTag(), removeTag() |
| Associations | setCompany(), removeCompany(), setContact(), removeContact(), moveToProject(), removeFromProject(), moveToPackage(), removeFromPackage() (packages: removeFromParentPackage()) |
| Documents | documents(), addDocument(DocumentUpload), removeDocument() |
| Access rights | accessRights($id) returns AccessRights with readRights(), writeRights(), grantRead(), revokeRead(), grantWrite(), revokeWrite(), grantTeamRead(), revokeTeamRead(), grantTeamWrite(), revokeTeamWrite() |
Delete-type operations return void and discard factro's response body. Package methods take $projectId before $packageId.
Users and tags
GET /users ignores query parameters and returns every user of the tenant (a few thousand rows), so cache the result. Employee tags are fetched per user; tagsForMany() sends the requests concurrently in batches and maps a 404 to an empty list:
$users = array_filter($client->users()->list(), fn (User $u) => $u->isActive && !$u->securityGroup->isGuest()); $tags = $client->users()->tagsForMany(array_column($users, 'id'), concurrency: 5); // user id => list<EmployeeTag> $all = $client->users()->employeeTags(); // GET /users/tags
Comments (comments(), addComment()), connections (connections(), addConnection()) and the project structure (projects()->structure(), a tree with find(), depthOf() and packageIds()) complete the read model. users() also manages users (create(), update(), delete()), substitutes (substitutes(), substitutedUsers(), addSubstitute(), removeSubstitute()), absences (absences(), absencesOf(), createAbsence(), updateAbsence(), deleteAbsence(), and the batch routes as createAbsences()/updateAbsences(), because createMany()/updateMany() of users() already batch users) and the seat quota (quota()).
Checklists, tags, associations and access rights
$entry = $client->tasks()->addChecklistEntry($taskId, new NewChecklistEntry('Call customer')); $client->tasks()->updateChecklistEntry($taskId, $entry->id, new ChecklistEntryChanges(checked: true)); $tag = $client->tasks()->createTaskTag('urgent'); $client->tasks()->addTag($taskId, $tag->id); $client->tasks()->moveToPackage($taskId, $packageId); // PUT /tasks/{id}/package $client->packages()->shiftWithSuccessors($projectId, $packageId, daysDelta: 5); $rights = $client->projects()->accessRights($projectId); $rights->grantTeamWrite($teamId); $reasons = $rights->readRights(); // employee id => list<AccessRightReason> foreach ($reasons[$employeeId] ?? [] as $r) { echo $r->reason, ' ', $r->packageId ?? $r->projectId ?? $r->taskId; // e.g. HasDirectPackageTeamWriteRight + packageId, teamId }
Documents
$document = $client->tasks()->addDocument($taskId, DocumentUpload::fromFile('/tmp/offer.pdf', 'application/pdf')); $all = $client->documents()->list(); $quota = $client->documents()->quota(); // maxDiskSpace, usedDiskSpace, freeDiskSpace()
Uploads are sent as one multipart/form-data part named file; the OpenAPI document does not describe the request body, so the part name is derived from the observed behaviour of the API.
Appointments, teams, contacts and templates
$client->appointments()->create(new NewAppointment($employeeId, $start, $end, subject: 'Kickoff')); $team = $client->teams()->create(new NewTeam('Backend', '#3366ff')); $client->teams()->addMember($team->id, $employeeId); $contact = $client->contacts()->create(new NewContact('Erika', 'Muster', emailAddress: 'erika@example.invalid')); $project = $client->templates()->applyStructureTemplate($templateId, new ApplyStructureTemplate( projectPropertyOverwrites: new ProjectPropertyOverwrites(title: 'Relaunch 2027'), ));
comments()->get($id) resolves any comment by id with its referenceType; notes() reads and writes note comments; todoLists()->list() returns the to-do lists with their elements; webhooks()->payloads($since) and payloadsByAction($action, $since) read delivered webhook payloads; customViews() manages saved views and their templates.
Work records
$records = $client->workRecords()->listByProject($projectId, CalendarDate::fromYmd('2026-01-01'), CalendarDate::fromYmd('2026-01-31')); $start = $records[0]->startsAt(new DateTimeZone('Europe/Berlin')); // uses the record's utcOffset when present $client->workRecords()->create(new NewWorkRecord( startDate: CalendarDate::fromYmd('2026-09-01'), minutesWorked: 90, utcOffsetMinutes: 120, bookedOnReferenceId: $taskId, bookedOnReferenceType: WorkRecordReferenceType::TASK, startTime: '09:00', isBillable: true, ));
Options
FactroOptions is readonly and built with named arguments. Invalid values (non-https baseUrl, timeouts <= 0, negative retry values, line breaks in userAgent) throw \InvalidArgumentException on construction:
| Option | Default | Purpose |
|---|---|---|
baseUrl |
https://cloud.factro.com/api/core |
test servers, fake endpoints in E2E tests |
requestTimeoutSeconds |
20.0 | max_duration of a request |
inactivityTimeoutSeconds |
10.0 | timeout (Symfony semantics: inactivity) |
timezone |
Europe/Berlin |
calendar days for CalendarDate fields in write DTOs |
policy |
RequestPolicy::all() |
allowed methods and denied path patterns |
limiterFactory |
sliding window 480/min, InMemoryStorage shared by all clients of the process |
one window per token within a PHP process; pass a factory with CacheStorage when several processes share one token |
maxRetries |
3 | GET only |
maxRetryAfterSeconds |
5 | longer Retry-After values surface as RateLimitException instead of blocking |
logger |
NullLogger |
one line per call |
clock |
NativeClock |
creationDate when creating tasks, MockClock in tests |
onRateLimit |
null |
closure receiving RateLimitInfo after every response, for metrics; limit, windowSeconds and resetSeconds are null when factro did not send them |
userAgent |
nxi-factro-sdk/<version> |
identification towards factro |
sendCreationDate |
true |
whether NewTask sends creationDate; the API's default is undocumented |
The token is a #[\SensitiveParameter] everywhere, is never logged and never appears in an exception message.
print_r() and var_dump() of FactroClient show the base URL and the policy, not the token. Symfony's VarDumper ignores __debugInfo() and still shows it. Stack traces of Symfony HttpClient carry the request options, token included, as call arguments: run production with zend.exception_ignore_args=On (the php.ini-production default) and configure error trackers to drop frame arguments.
Errors
All exceptions implement Nxi\Factro\Exception\FactroException.
| Exception | When |
|---|---|
AuthenticationException |
HTTP 401 (invalid token) |
ForbiddenException |
HTTP 403, the token may not perform this operation (for example "Task is closed") |
NotFoundException |
HTTP 404; find*() methods turn it into null |
ValidationException |
HTTP 400 and 422, errors() returns the body's error list |
RateLimitException |
HTTP 429, retryAfterSeconds() from Retry-After or the ratelimit header |
ServerException |
HTTP 500 to 599 |
UnexpectedResponseException |
success status, but the body is not a JSON object or array (HTML page, truncated JSON, scalar) |
FactroRequestException |
every other status of 400 or higher; base class of the above |
TransportException |
DNS, TCP, TLS or timeout failure; possiblyExecuted() is true for POST, PUT, DELETE |
OperationNotPermittedException |
the RequestPolicy forbids the call; thrown before anything is sent |
HydrationException |
a response field is missing or has an unexpected type, or a timestamp names an impossible date or time |
FactroRequestException carries method, path, statusCode, rawBody, headers and offers factroMessage() (from a JSON message/error/detail/title or the plain-text body).
Ids that end up in a request path must be UUIDs (shape only, any case). Anything else, including '', . and .., throws \InvalidArgumentException before a request is built, so an id can never change the route or slip past a RequestPolicy deny pattern.
Dates
factro stores dates as UTC timestamps in three variants: T22:00Z and T23:00Z (created in the UI, local midnight in Europe/Berlin) and T00:00Z (created through the API with a plain date). DTOs expose them unchanged as DateTimeImmutable in UTC. Use CalendarDate::fromFactro($utc, $timezone) to get the intended calendar day and CalendarDate in write DTOs; toFactro() produces local midnight in UTC, the storage form of the UI.
The timezone option defaults to Europe/Berlin because the factro UI stores calendar days as local midnight in that zone. With this default, dates written through the SDK look the same as dates entered in the UI. Change it only if your tenant's users work in a different time zone.
Work records use their own representation: startDate/endDate as Y-m-d, startTime/endTime as HH:MM, utcOffset in minutes. Comments and work records carry JavaScript timestamps (milliseconds) in changeDate, createdAt and updatedAt; FactroDateTime::fromJsTimestamp() converts them.
Testing
Pass a MockHttpClient as base client; the SDK ships hand-written response fixtures:
use Nxi\Factro\Testing\Fixtures; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\JsonMockResponse; $mock = new MockHttpClient(fn ($method, $url) => JsonMockResponse::fromFile(Fixtures::path('task')), 'https://factro.test/api/core/'); $client = FactroClientFactory::create('test-token', new FactroOptions(baseUrl: 'https://factro.test/api/core', maxRetries: 0), $mock);
Fixtures::json('tasks-by-project') returns the decoded rows, Fixtures::raw('errors/404-task.txt') the text bodies. See fixtures/README.md for the placeholder conventions.
Testing factories
Nxi\Factro\Testing\Factory\* (Task, Project, Package, User, Company, Contact, TaskComment, WorkRecord, EmployeeTag, Document, Appointment, Team, Absence, ChecklistEntry, TodoList, CustomView) build rows and DTOs from the fixtures; they live under src/ so consumers can use them without require-dev:
$row = TaskFactory::make(['title' => 'Custom', 'executorId' => null]); // array, overrides merged with array_replace $task = TaskFactory::dto(['taskState' => 'closed']); // Nxi\Factro\Resource\Task\Output\Task $rows = ProjectFactory::many(3); // distinct ids "<fixture-id>-<n>", increasing numbers
Keeping up with the API
docs/openapi/openapi.json holds a local copy of the factro OpenAPI document (not tracked, created by make openapi-fetch); bin/openapi-diff <old.json> <new.json> lists added and removed operations and changed schema properties. See docs/openapi/README.md for the extraction command.
Symfony integration
No bundle is needed:
# config/packages/framework.yaml framework: http_client: scoped_clients: factro.base: base_uri: '%env(FACTRO_API_URL)%' # config/services.yaml services: Nxi\Factro\FactroOptions: arguments: $logger: '@monolog.logger.factro' $policy: !service { class: Nxi\Factro\Policy\RequestPolicy, factory: [Nxi\Factro\Policy\RequestPolicy, withoutDeletes] } Nxi\Factro\FactroClient: factory: [Nxi\Factro\FactroClientFactory, create] arguments: $token: '%env(FACTRO_API_TOKEN)%' $options: '@Nxi\Factro\FactroOptions' $httpClient: '@factro.base'
In multi-tenant applications where the token changes per user, do not register FactroClient as a service; call the factory per request with the framework's scoped client as base.
Versioning
The SDK follows Semantic Versioning: breaking changes only come with a new major version. Classes, methods and namespaces marked @internal (Nxi\Factro\Http, Nxi\Factro\Mapping, Resource\AbstractResource, toPayload()/toMultipart() of the DTOs) are not covered and may change in any release. CHANGELOG.md lists the changes of every release.
Contributing
See .github/CONTRIBUTING.md for setup, Make targets and pull request rules.
Architecture decisions are recorded in docs/DECISIONS.md.