rjvandoesburg/laravel-jira-rest-client
Laravel Jira REST Client offers a clean, configurable wrapper around Atlassian Jira's REST API, so Laravel apps can fetch issues, projects, users, and more through an expressive facade-driven interface. Install via Composer, set credentials, and start querying Jira quickly.
Install and wire it up
composer require rjvandoesburg/laravel-jira-rest-client
Laravel 5.5+ auto-discovers the service provider. Publish the config file so you can see what knobs exist:
php artisan vendor:publish --tag=jira
# or, if the tag isn't registered:
php artisan vendor:publish --provider="rjvandoesburg\Jira\JiraServiceProvider"
The package is a thin Laravel binding around the Jira REST API, so the first thing to do is open config/jira.php and check the exact key names it expects — they map to the underlying client (host, username/email, password or API token, API version). Do not assume; read the file.
Environment
JIRA_HOST=https://your-org.atlassian.net
[email protected]
JIRA_PASSWORD=<api-token-for-cloud / password-for-server>
# depending on version of the package you may also see:
JIRA_API_VERSION=2
JIRA_PROJECT_KEY=PROJ
Use an API token for Jira Cloud, not your account password. For Server/Data Center, basic auth with username/password or a personal access token is typical.
First use case — prove connectivity and read one issue
Resolve the bound client or the Jira facade in tinker:
php artisan tinker
>>> $jira = app(\rjvandoesburg\Jira\Jira::class); // or the facade alias
>>> $issue = $jira->issues()->get('PROJ-1');
>>> $issue->fields->summary;
If that returns a summary, you're done with setup. If it 401s, it's auth; if it 404s, it's host/API version; if it hangs, it's a network or SSL issue. Fix that before writing any application code — every subsequent debugging session is easier when you know the transport works.
Where to look first in the repo
src/JiraServiceProvider.php — what's bound in the container, and under what name.config/jira.php — the actual config keys and defaults.lesstif/php-jira-rest-client, so its docs are the real API reference).Inject the client, don't sprinkle the facade
Facades are tempting but they make your domain code untestable and hide the dependency. Bind your own service and inject the Jira client into it:
namespace App\Services;
use rjvandoesburg\Jira\Jira;
class IssueTracker
{
public function __construct(private Jira $jira) {}
public function openBug(string $project, string $summary, string $description): string
{
$response = $this->jira->issues()->create([
'fields' => [
'project' => ['key' => $project],
'summary' => $summary,
'description' => $description,
'issuetype' => ['name' => 'Bug'],
],
]);
return $response->key; // e.g. PROJ-42
}
}
Everything else in the app talks to IssueTracker, not to Jira. This is the single highest-leverage decision: when Jira's API changes or you switch wrapper packages, you edit one class.
Push ticket creation to the queue
Jira is an external network call. Any write path triggered by a user request (support form, error report, order exception) should dispatch a job:
CreateJiraIssue::dispatch($payload)->onQueue('integrations');
Give the job $tries = 3, a backoff(), and a failed() method that logs with enough payload to reproduce manually. Jira will occasionally return 5xx during maintenance windows, and you do not want that surfacing as a 500 to your user.
Model the workflow as transitions, not status writes
The common integration is "move ticket to Done when the deploy finishes." Jira does not accept a status name — you must look up valid transitions for the issue and post the transition ID:
$transitions = $this->jira->issues()->getTransitions($key);
$target = collect($transitions)->first(
fn ($t) => strcasecmp($t->name, 'Done') === 0
);
if ($target) {
$this->jira->issues()->transition($key, [
'transition' => ['id' => $target->id],
]);
}
Cache the transition map per project per process — it is stable and you don't want an extra round trip on every status change.
Inbound webhooks + outbound actions
The most valuable pattern for a Laravel app is bidirectional sync: Jira webhooks hit your route, you verify and enqueue, then reconcile against your own model. Keep the webhook route dumb:
Route::post('/webhooks/jira', JiraWebhookController::class)
->middleware('verify.jira.secret');
The controller should record the event and dispatch HandleJiraWebhook. Do the Jira API calls inside the job, not the controller — webhook timeouts cause Jira to retry aggressively and you'll get duplicates.
Resolve custom fields dynamically
Custom fields (customfield_10123) are per-instance and per-project. Don't hardcode them. On boot or on a scheduled RefreshJiraFieldMap command, hit the field metadata endpoint, filter by name, and store the ID in config or cache:
$fields = $this->jira->fields()->getAll();
$storyPoints = collect($fields)->firstWhere('name', 'Story Points')->id;
Testing
Never hit a real Jira instance in tests. Because the client is container-bound, you can swap it:
$this->mock(IssueTracker::class, function ($mock) {
$mock->shouldReceive('openBug')->once()->andReturn('PROJ-42');
});
For integration-style tests of your wrapper, prefer Guzzle's mock handler or Laravel's Http::fake() if the underlying client routes through the Laravel HTTP client. Assert on the outgoing request shape — that's where integrations actually break.
Cloud vs Server auth is the #1 support ticket. Jira Cloud requires email + API token over basic auth. Jira Server expects a username, and increasingly a personal access token via Authorization: Bearer. If you get a 401 with correct-looking credentials, check which mode your JIRA_HOST implies.
API version mismatch silently changes payloads. v2 uses plain strings for description; v3 requires Atlassian Document Format (ADF). Sending a string to a v3 endpoint gives you a 400 that reads like a schema error. Pick a version in config and write a small adf() helper that wraps a paragraph, so your call sites stay clean.
The description field is the usual culprit when a create call works for one project and fails for another — a required custom field or a screen configuration difference. Log the full Guzzle response body, not just the message, on every failure. The errorMessages and errors keys tell you exactly which field is wrong.
Config caching bites in production. php artisan config:cache bakes env values in. If you deploy to a container where JIRA_PASSWORD is injected at runtime but config was cached at build time, you'll authenticate as nobody. Either cache after env is set, or read the secret from a runtime source instead of env() inside the config file.
Facade aliasing. If Jira:: resolves to null or throws "class not found," check config/app.php for the alias — older Laravel or manual registration needs it added, and package aliases sometimes differ from the README.
Rate limiting is real. Jira Cloud throttles per-app and per-user, and returns 429 with Retry-After. Bulk operations (importing 500 issues, mass transitions) should batch with a small delay and honor the header. A naive foreach over a large collection is the classic way to get your integration temporarily banned.
Transitions lie by omission. A transition can be present for you but absent for the issue's assignee, or gated behind required fields. Always check getTransitions() returns the one you want, and never assume a status name maps to a single transition name (workflows get renamed constantly).
Wrap the client — do not let it leak. If rjvandoesburg\Jira\... classes appear in your controllers, models, or form requests, you've coupled your app to a 45-star wrapper. Keep the imports inside one App\Services\Jira\* namespace.
Timeouts are not optional. The default Guzzle timeout will happily hold a PHP-FPM worker for 30+ seconds when Jira is slow. Set an explicit 5–10 second timeout and a connect timeout on the client, and always call Jira from a job or command, never from a page render.
Extension point: the container binding. If you need custom retry middleware, logging of request/response bodies, or a different Guzzle client, rebind the client in your own service provider after the package's provider registers. That's the cleanest seam this package gives you — use it rather than editing vendor code.
Version the API contract in your own tests. Because the package is a thin wrapper, upstream breaking changes surface as runtime errors, not compile errors. A handful of feature tests that assert request payload shape will catch the upgrade that renames a method.
How can I help you explore Laravel packages today?