Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Redmine Api Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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').

Implementation Patterns

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.

Gotchas and Tips

  • API churn is real. 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).
  • Clearing 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.
  • 404 means "not allowed" as often as "not found." Redmine hides objects the API user cannot see. When a colleague's integration "loses" projects, the cause is almost always the API key's user lacking a role on that project — not the package. 403 is a genuine permission denial on an action. Log the API key's owner in your error context.
  • Custom fields are ID-based. '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.
  • Send only changed fields on update. 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.
  • Time entries need an 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.
  • Dates and times are naive strings. Redmine returns ISO-8601-ish timestamps without a reliable offset in many fields; parse with Carbon and treat them as the Redmine server's timezone, not config('app.timezone').
  • Failures are exceptions, not false returns. Catch 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.
  • No built-in retry or idempotency. Retrying a failed 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.
  • Self-signed Redmine instances need 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.
  • Testing: don't mock the Redmine HTTP endpoints. Fake your own gateway interface, or if you must test at the transport level, use 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect