openeuropa / openproject-php-client
PHP client for the OpenProject API v3
Package info
github.com/openeuropa/openproject-php-client
pkg:composer/openeuropa/openproject-php-client
Requires
- php: >=8.3
- php-http/multipart-stream-builder: ^1.4
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- esi/phpunit-coverage-check: ^3.1
- guzzlehttp/guzzle: ^7
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12
- squizlabs/php_codesniffer: ^3.13
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-22 19:54:18 UTC
README
PHP client for the OpenProject API v3.
Requirements
- PHP >= 8.3
- An OpenProject instance; the client is tested against OpenProject 17.
- A PSR-18 HTTP client and PSR-17 factories of your choice (e.g. Guzzle 7). The library does not depend on a concrete HTTP client implementation.
- The PSR-18 client should be configured to follow HTTP redirects: some
functionality, such as attachment downloads, relies on them. It should also
strip the
Authorizationheader when a redirect points to a different host, so your API token is never forwarded to a third party. Guzzle 7 and Symfony's HttpClient both do this by default.
Installation
composer require openeuropa/openproject-php-client
# Plus a PSR-18/PSR-17 implementation, if your project does not have one yet:
composer require guzzlehttp/guzzle
Usage
The client speaks HAL+JSON to the OpenProject API v3. Every resource is
reached through a method on Client ($client->projects(),
$client->workPackages(), ...) that returns an endpoint object; responses
decode to immutable, readonly DTOs.
Classes and methods tagged @internal are not part of the public API and
can change in any release.
Authentication
ApiKeyAuthentication takes an API token, generated in the OpenProject web
UI under My account > Access tokens:
use OpenEuropa\OpenProjectClient\Authentication\ApiKeyAuthentication; $authentication = new ApiKeyAuthentication($apiKey);
The token inherits the permissions of the user who generated it, so that user's access is what the client can read and write.
The OpenProject API also accepts Bearer tokens and OAuth2, but the API key
is the only strategy this library ships. To use another, implement
AuthenticationInterface and pass your own instance to Client.
Constructing the client
This library never uses auto-discovery (php-http/discovery or otherwise) to
find an HTTP layer. You inject the authentication strategy, a PSR-18 client,
and both PSR-17 factories explicitly:
use OpenEuropa\OpenProjectClient\Client; $client = new Client( 'https://example.org', $authentication, new \GuzzleHttp\Client(), // Symfony: new \Symfony\Component\HttpClient\Psr18Client() new \GuzzleHttp\Psr7\HttpFactory(), // Symfony: new \Nyholm\Psr7\Factory\Psr17Factory() new \GuzzleHttp\Psr7\HttpFactory(), // Symfony: new \Nyholm\Psr7\Factory\Psr17Factory() );
Guzzle is only the concrete example here (see Installation);
any PSR-18 client and PSR-17 request/stream factories work. The Symfony
alternatives come from symfony/http-client and nyholm/psr7.
The base URL is the instance root, without /api/v3 (https://example.org,
not https://example.org/api/v3). Subpath-hosted instances work the same way
(https://example.org/openproject).
Quick start
info() is the smallest authenticated call the API offers (GET /api/v3),
so it doubles as a connectivity and credentials smoke test.
$info = $client->info(); $info->instanceName; // e.g. "My OpenProject instance"
Representative reads:
$project = $client->projects()->get(1); $workPackage = $client->workPackages()->get(42);
Reading responses
A related resource, such as a work package's status or assignee, is a
Link with a title and an id():
$workPackage->status?->title; // e.g. "In progress" $workPackage->assignee?->id(); // e.g. 5, or null when unassigned
Long text, such as a description, has the text as written in raw and the
rendered version in html:
$workPackage->description?->raw; $workPackage->description?->html;
A date, such as a due date, is a DateTimeImmutable at midnight in PHP's
default time zone, because the API sends dates without one. A timestamp
keeps the offset the API sent:
$workPackage->dueDate?->format('Y-m-d'); // e.g. "2026-06-29" $workPackage->createdAt?->format('c'); // e.g. "2026-06-18T09:44:04+00:00"
Custom fields are in customFields (on work packages, time entries, and
users):
// Text, number, date, and similar fields. $workPackage->customFields->values['customField3']; // e.g. "ACME Corp" // List, user, and version fields: always a list of links. $workPackage->customFields->links['customField4'][0]->title; // e.g. "High"
Pagination
Collection endpoints expose up to two read shapes, depending on how the API models the collection:
| Endpoints | Methods |
|---|---|
statuses(), types(), priorities() |
all(): PageResults only. The API models these collections as unpaginated. |
projects(), users(), workPackages(), timeEntries(), memberships() |
all(?FilterSet, ?CollectionParams): \Generator and listPage(?FilterSet, ?CollectionParams): PageResults |
relations(), versions() |
all(?FilterSet $filters = null): PageResults only. Filterable, but unpaginated in the same way. |
all() on the generator-backed endpoints paginates automatically:
foreach ($client->workPackages()->all() as $workPackage) { $workPackage->subject; }
listPage() returns a single PageResults instead, for callers that need
the totals or control over paging. It carries total, count,
elements, pageSize, offset, and nextByOffset (the raw link the API
uses to signal whether another page exists), and it is itself iterable:
$page = $client->workPackages()->listPage(); $page->total; // count across every page, not just this page's elements foreach ($page as $workPackage) { // Only this page's elements. }
Some endpoints can also list by parent, e.g.
workPackages()->listByProject($projectId) or
activities()->allByWorkPackage($workPackageId).
Unlike the raw API, work package listings include closed work packages unless you filter them out.
Filters
FilterSet composes one or more Filter instances into the ANDed filters
query parameter the API expects:
use OpenEuropa\OpenProjectClient\Query\Filter; use OpenEuropa\OpenProjectClient\Query\FilterOperator; use OpenEuropa\OpenProjectClient\Query\FilterSet; $filters = new FilterSet( new Filter('status', FilterOperator::EQUAL, ['1']), new Filter('assignee', FilterOperator::IS_NULL, null), ); $page = $client->workPackages()->listPage($filters);
Filter values are always strings, in the shape the API's filter grammar
expects rather than PHP's native types: an id filters as "5", not 5;
a boolean filters as "t" or "f", not true/false. Operators that take
no values (IS_NULL, NOT_NULL, TODAY, and similar) take null rather
than an empty array.
CollectionParams covers the non-filter query parameters:
use OpenEuropa\OpenProjectClient\Query\CollectionParams; $params = new CollectionParams( offset: 2, // 1-based page number pageSize: 25, sortBy: [['id', 'desc']], groupBy: 'status', showSums: true, ); $page = $client->workPackages()->listPage($filters, $params);
The two-step form flow
Projects, work packages, versions, time entries, and memberships validate
through a form endpoint before you commit the write: createForm() (and
updateForm() for existing resources) posts a payload for validation and
returns a Form carrying the (possibly corrected) payload, the schema
(allowed values and writability per property, as SchemaProperty), and any
validationErrors keyed by property name. create() and update() do not
run the form step for you. Relations, attachment uploads, and work-package
comments have no form step.
Call createForm() without a payload to get a blank form listing the
allowed values; workPackages()->createForm($input, $projectId) limits
them to one project.
Please note that form endpoints answer HTTP 200 even when the payload is
invalid. Check $form->hasErrors(), not the response status.
use OpenEuropa\OpenProjectClient\Model\WorkPackageInput; $input = (new WorkPackageInput()) ->subject('Investigate flaky test') ->project($projectId) ->type($typeId); $form = $client->workPackages()->createForm($input); if ($form->hasErrors()) { foreach ($form->validationErrors as $property => $error) { // $error->attribute, $error->message, $error->identifier } } else { $workPackage = $client->workPackages()->create($input); }
The *Input classes (WorkPackageInput, ProjectInput, RelationInput,
and so on) are fluent builders for the write payload; createForm() and
create() accept the same instance.
Work package, project, version, and time entry inputs also set custom fields:
$input = (new WorkPackageInput()) ->customField(3, 'ACME Corp') // text, number, date, and similar fields ->customFieldLink(4, $optionId, 'custom_options'); // list field; 'users' or 'versions' for those
The builders have methods for the common fields. To send a field they don't
cover, use set() with the API's property name, e.g.
->set('remainingTime', 'PT2H').
Exceptions
Every exception the client throws extends OpenProjectException (itself a
\RuntimeException). PSR-18 exceptions from the injected HTTP client are
mapped too, so nothing else reaches your code.
Status-carrying failures extend the intermediate ApiException, which
exposes httpStatus and errorIdentifier:
| HTTP status | Exception |
|---|---|
| 401 | AuthenticationException |
| 403 | ForbiddenException |
| 404 | NotFoundException |
| 409 | ConflictException |
| 422 | ValidationException (adds errors, a list<ValidationError>) |
| any other non-2xx below 500, including an unfollowed 3xx redirect | ApiErrorException |
Two more sit directly under OpenProjectException, not ApiException:
ApiUnreachableException (a network-level failure the PSR-18 client raised,
or an HTTP >= 500 response) and UnexpectedResponseException (a response
body missing a field the client cannot do without, such as an id). A body
that is not a JSON object at all raises OpenProjectException itself.
use OpenEuropa\OpenProjectClient\Exception\ValidationException; try { $client->workPackages()->create($input); } catch (ValidationException $e) { foreach ($e->errors as $error) { // $error->attribute, $error->message, $error->identifier } }
Retries
The client never retries a request; configure retries on the HTTP client
you inject. ApiUnreachableException marks the failures worth retrying, but
retrying a write after a timeout can create the resource twice.
Write-side notes
-
workPackages()->update()requires the work package's currentlockVersionand raisesConflictExceptionwhen it is stale. Read the work package first and pass back thelockVersionyou got from that read:$workPackage = $client->workPackages()->get(42); $updated = $client->workPackages()->update( $workPackage->id, (new WorkPackageInput())->subject('New subject'), $workPackage->lockVersion, );
-
Work-package
create()/update()andactivities()->comment()all take a trailing$notify = trueparameter controlling whether OpenProject notifies the involved users. -
relations()->create(int $workPackageId, RelationInput $payload)is scoped to a work package, unlike the other endpoints'create()methods. -
projects()->delete()is asynchronous: it returns as soon as the project is archived, and the row disappears shortly after via a background job. Every other endpoint'sdelete()is synchronous. -
Attachments upload through
attachments()->upload()(standalone) orattachments()->uploadToWorkPackage(), which take the file contents as a readable PSR-7StreamInterface. Create it with the stream factory you pass toClient:// From a file. $stream = $streamFactory->createStreamFromFile('/path/to/report.pdf'); // From content already in memory. $stream = $streamFactory->createStream($contents); $client->attachments()->uploadToWorkPackage(42, $stream, 'report.pdf', 'application/pdf');
Downloads go through
attachments()->download(), which returns a PSR-7StreamInterface; see Requirements for the redirect-following requirement this relies on.
Building DTOs
To build a DTO yourself, for example as the return value of a test double,
use its fromApiResponse() with the decoded JSON body the API would
return; the constructors are @internal. A JSON fixture captured from an
instance works as-is.
use OpenEuropa\OpenProjectClient\Model\PageResults; use OpenEuropa\OpenProjectClient\Model\WorkPackage; $data = [ 'id' => 42, 'subject' => 'Fix login', '_links' => [ 'status' => ['href' => '/api/v3/statuses/1', 'title' => 'New'], ], ]; $workPackage = WorkPackage::fromApiResponse($data); // A collection takes the element factory as its second argument. $page = PageResults::fromApiResponse( ['total' => 1, 'count' => 1, '_embedded' => ['elements' => [$data]]], WorkPackage::fromApiResponse(...), );
Only id and the resource's name (subject on a work package, fileName on
an attachment, name on the others that have one) must be present; any other
field can be left out and reads as null.
Development
All tooling runs in Docker; no local PHP installation is needed.
# Install dependencies. docker compose run --rm php composer install # Check coding standards (PSR-12). docker compose run --rm php composer phpcs # Auto-fix coding standard violations. docker compose run --rm php composer phpcbf # Run static analysis (PHPStan, level max). docker compose run --rm php composer phpstan # Run the test suite (PHPUnit). docker compose run --rm php composer test # Run the test suite with a coverage report; fails below 100% coverage. docker compose run --rm php composer coverage
The container is built from php:8.3-cli (the minimum supported PHP version)
with Composer included; the image is built automatically on first run. Xdebug
is installed but off by default — the coverage script enables it for the
run, and -e XDEBUG_MODE=debug enables step debugging.
Integration tests
composer test runs the unit suite only (mock-based, no server). A separate
integration suite drives the client against a real, throwaway OpenProject
instance and skips unless OPENPROJECT_API_TOKEN is set. Bring the instance up
behind its compose profile and pass the token (the base URL and Host header
default to the compose instance, so nothing else is needed):
docker compose --profile openproject up -d # first boot seeds, minutes scripts/wait-for-openproject.sh # poll until the API answers docker compose --profile openproject run --rm \ -e OPENPROJECT_API_TOKEN="$(scripts/generate-api-token.sh)" \ php composer test:integration docker compose --profile openproject down -v # throw the instance away
License
Licensed under the EUPL-1.2.