kbsali/redmine-api
PHP client for the Redmine REST API that simplifies integrating Redmine into your applications. Access projects, issues, users, time entries, versions, and more through a clean, object-oriented interface with straightforward authentication and response handling.
composer require kbsali/php-redmine-api:^2.9
There is no Laravel service provider, facade, or config file — it is a plain PHP SDK. The fastest path is to bind it as a singleton so you are not reconstructing it per call:
// config/services.php
'redmine' => [
'url' => env('REDMINE_URL'), // https://redmine.example.com
'api_key' => env('REDMINE_API_KEY'), // user's "API access key" from /my/account
],
// App\Providers\AppServiceProvider::register()
$this->app->singleton(\Redmine\Client\Client::class, fn () => new \Redmine\Client\NativeCurlClient(
config('services.redmine.url'),
config('services.redmine.api_key'),
));
Note the class names: on v2.4+ the entry point is Redmine\Client\Client (with NativeCurlClient and Psr18Client implementations); older tutorials show Redmine\Client or Redmine\Client\NativeCurlClient only. As of v2.9.1 the 2.x line is still the current release line (v2.10.0 is in housekeeping), so pin ^2.9 and read the changelog before jumping a minor.
First use case — list open issues in a project and create one:
use Redmine\Client\Client;
public function __construct(private Client $redmine) {}
$issues = $this->redmine->getApi('issue')->list([
'project_id' => 42,
'status_id' => 'open',
'limit' => 100,
'sort' => 'updated_on:desc',
]);
// ['issues' => [...], 'total_count' => 137, 'offset' => 0, 'limit' => 100]
$created = $this->redmine->getApi('issue')->create([
'project_id' => 42,
'subject' => 'Checkout 500s on Safari',
'tracker_id' => 1,
'priority_id' => 3,
'description' => "...",
]);
// ['issue' => ['id' => 9001, ...]]
Where to look first: vendor/kbsali/php-redmine-api/src/Redmine/Api/ — one class per Redmine resource (Issue, Project, User, TimeEntry, Attachment, Wiki, Search, Version, Membership, Group, Role, CustomField, Query, News, IssueStatus, Tracker, IssueCategory, IssueRelation, TimeEntryActivity). Every one follows the same list()/show()/create()/update()/remove() shape with occasional extras, so once you know Issue you know 80% of the surface. Reach for it via the singular string key: getApi('time_entry'), getApi('issue_status').
Wrap it, don't sprinkle it. The package returns raw associative arrays with Redmine's quirks (nested under issue/issues, numeric-string IDs, no DTOs). Introduce one app-level service (App\Services\Redmine\RedmineGateway) that hides the client, maps responses to your own DTOs/Value Objects, and gives you a single seam to fake in tests. Letting controllers call $client->getApi('issue') directly works for a spike and hurts within a sprint.
Reads: cache and paginate. list() caps out at 100 records per request regardless of what you ask for. Write a generator instead of hand-rolling offsets:
function allIssues(Client $c, array $filters = []): \Generator {
$offset = 0;
do {
$page = $c->getApi('issue')->list($filters + ['limit' => 100, 'offset' => $offset]);
yield from $page['issues'] ?? [];
$offset += 100;
} while ($offset < ($page['total_count'] ?? 0));
}
Wrap reference data (trackers, statuses, activities, projects) in Cache::remember with a long TTL — these change rarely, and fetching them on every form render is the usual first performance complaint. Do the reverse for issue lists: cache briefly (30–60s) or not at all, because visibility depends on the API key's user.
Writes: queue them. Redmine is a Rails app behind a modest server; a loop of 200 create() calls from a web request will time out or get you throttled by a proxy. Put writes in ShouldQueue jobs, one job per issue or chunked, with $tries = 3 and backoff(). There are no bulk endpoints, so throughput is one HTTP round trip per object.
Attachments are two-step. Upload the file first to get a token, then reference it on create/update:
$token = $this->redmine->getApi('attachment')->upload($absolutePath);
// returns ['upload' => ['token' => '...']]
$this->redmine->getApi('issue')->update($id, [
'notes' => 'Log attached',
'uploads' => [[
'token' => $token['upload']['token'],
'filename' => basename($absolutePath),
'content_type' => 'text/plain',
]],
]);
Tokens are short-lived, so upload and attach in the same job, not across queue hops.
Use the PSR-18 variant when you need observability. NativeCurlClient uses ext-curl directly, which means Laravel's Http facade, Http::fake(), retry macros, and your logging middleware are all bypassed. Swap in Psr18Client with a Guzzle client configured with retry/logging middleware if you need request tracing, custom timeouts, or a proxy:
$guzzle = new \GuzzleHttp\Client(['timeout' => 15, 'connect_timeout' => 5]);
$client = new \Redmine\Client\Psr18Client(
$guzzle,
new \GuzzleHttp\Psr7\HttpFactory(), // PSR-17 request factory
new \GuzzleHttp\Psr7\HttpFactory(), // PSR-17 stream factory
config('services.redmine.url'),
config('services.redmine.api_key'),
);
Sync pattern. If you mirror issues locally, pull incrementally with updated_on => '>=YYYY-MM-DD' plus sort => 'updated_on:asc', and store Redmine's id as a unique key. Redmine has no webhooks out of the box, so polling on a schedule (every 5–15 min) is the standard approach; the news/issue list endpoints are your only change feed.
Redmine\Client (v1), Redmine\Client\Client (v2.3+), and the NativeCurlClient/Psr18Client split (v2.4) all appear in blog posts and Stack Overflow answers. Lock ^2.9 and read the changelog before upgrading; method signatures on some APIs (Wiki, Membership, Search, Version) are not uniform with the rest. v2.9.1 is a patch release with no breaking changes — its only behavioural change is the Issue::update() fix below, plus an internal refactor to route API construction through ClientApiTrait::fromHttpClient() (no action needed, but if you subclassed or reflected into the trait, re-check).fixed_version_id now works (v2.9.1+). Before this fix, passing an empty value in Issue::update() was swallowed and the version stayed attached. If you are on <= 2.9.0, "unassign the target version" silently fails; upgrade and send 'fixed_version_id' => '' to clear it. This is the one concrete reason to bump the patch version in a Redmine-sync codebase.'custom_fields' => [['id' => 7, 'value' => 'x']] — names are ignored. Enumeration/version/category values must be exact strings or IDs, and status changes can be silently dropped by workflow rules (no error, field just doesn't apply). Verify by re-fetching show() after update() in any critical flow.update() passes the payload straight through; omitted fields are untouched, but sending status_id you didn't mean to change can trigger spurious journal entries. Comments go in notes, and private_notes => true works. The flip side of the v2.9.1 fix: an explicitly-sent empty string is now meaningful for fixed_version_id, so don't "clean up" payloads by stripping empties if you rely on unsetting.activity_id. There is no default assumed by the API; fetch valid ones via getApi('time_entry_activity')->list() and cache. spent_on must be Y-m-d, and hours is a float.config('app.timezone').Redmine\Exception\ClientException for HTTP errors and Redmine\Exception\MissingParameterException for local validation (Issue::create without subject, Project::create without identifier and name). Wrap these into domain exceptions so upstream code isn't importing package namespaces.create() can produce duplicate issues. When a job is retried, do a lookup first (e.g. by a marker you embed in the description) or accept duplicates deliberately.checkSslCertificate = false (4th argument of NativeCurlClient) or Guzzle's verify => false. Do it via env-gated config, never committed defaults.limit is capped server-side (usually 100, configurable in settings.yml), and offset past total_count returns an empty set rather than an error — a common silent bug in pagination loops that compare against a stale count.Psr18Client with php-http/mock-client and assert on request URIs. Http::fake() will not intercept anything with NativeCurlClient. Add one regression test that updates an issue with fixed_version_id => '' and asserts the follow-up show() has no version — that is the behaviour v2.9.1 changed.How can I help you explore Laravel packages today?