medicorenl/jira-api-bundle
A PHP bundle for integrating Jira's REST API into your application. It provides configuration and service wiring for authentication and common Jira operations such as issues, projects, and related resources, reducing boilerplate when connecting your app to Jira.
Reality check before you install anything. This is a Symfony2 bundle (Symfony 2.x went EOL in 2018), with 0 dependents and no Stars. Treat it as a reference implementation of the Jira REST calls you need, not as a dependency you can drop into a modern Laravel app. Composer will almost certainly refuse to install it next to Laravel because the bundle pins symfony/* to ~2.x and typically an old Guzzle (guzzle/guzzle 3.x, namespace Guzzle\Http\Client), while Laravel ships Symfony 6/7 components and Guzzle 7 (GuzzleHttp\Client).
Step 1 — Read the repo before writing code. Clone it and open these files in order:
| File | Why |
|---|---|
composer.json |
PHP constraint, symfony/symfony version, Guzzle package + version. This decides whether integration is possible. |
DependencyInjection/Configuration.php |
The exact config keys (host, username, password/token, port). |
DependencyInjection/*Extension.php + Resources/config/services.yml |
The service ID you'd inject. |
Api/ or JiraApi.php (top-level class) |
The method surface — this is the part you actually want to reuse. |
Tests/ |
Real, runnable usage examples; usually the best documentation in a bundle this small. |
Step 2 — Verify the service ID (if you do run it inside a Symfony2 app):
php app/console debug:container | grep -i jira
Step 3 — Minimal config (Symfony2 app/config/config.yml, keys per Configuration.php):
medicore_jira_api:
host: "https://your-domain.atlassian.net"
username: "%jira_user%"
password: "%jira_password%"
Step 4 — First use case: pull a single issue and render it. That is the smallest end-to-end proof that auth, host, and SSL work:
$jira = $this->get('medicore_jira.api'); // confirm real ID via debug:container
$issue = $jira->getIssue('PROJ-123');
echo $issue['fields']['summary'];
Step 5 — The Laravel path. Do not composer require it. Instead skim the bundle's client class, then write a 40-line App\Services\JiraClient against Guzzle 7 that mirrors the same endpoints (/rest/api/2/issue/{key}, /rest/api/2/search, /rest/api/2/issue POST). You get the same functionality with zero version conflicts. Your first Laravel use case is almost always: "when a support ticket is created, open a Jira issue and store the returned key."
Pattern 1 — Bind as a singleton in a service provider. One HTTP client, one connection pool, config from config/jira.php:
// app/Providers/JiraServiceProvider.php
public function register(): void
{
$this->app->singleton(JiraClient::class, fn () => new JiraClient(
new \GuzzleHttp\Client([
'base_uri' => rtrim(config('jira.host'), '/') . '/',
'timeout' => config('jira.timeout', 15),
'auth' => [config('jira.username'), config('jira.token')],
'headers' => ['Accept' => 'application/json'],
])
));
}
// config/jira.php
return [
'host' => env('JIRA_HOST'),
'username' => env('JIRA_EMAIL'), // Cloud: your email, not a login name
'token' => env('JIRA_API_TOKEN'),
'timeout' => 15,
];
Pattern 2 — Thin client + typed accessors. Keep the HTTP layer dumb and put Jira's quirks (custom fields, ADF descriptions) in a wrapper:
class JiraClient
{
public function __construct(private \GuzzleHttp\Client $http) {}
public function issue(string $key): array
{
return $this->request('GET', "rest/api/2/issue/{$key}");
}
public function createIssue(array $fields): string
{
return $this->request('POST', 'rest/api/2/issue', ['fields' => $fields])['key'];
}
/** @return array{issues: array, total: int} */
public function search(string $jql, int $startAt = 0, int $max = 50): array
{
return $this->request('GET', 'rest/api/2/search', [
'jql' => $jql, 'startAt' => $startAt, 'maxResults' => $max,
]);
}
private function request(string $method, string $path, array $data = []): array
{
try {
$res = $this->http->request($method, $path, [
$method === 'GET' ? 'query' : 'json' => $data,
]);
return json_decode((string) $res->getBody(), true, 512, JSON_THROW_ON_ERROR);
} catch (\GuzzleHttp\Exception\ClientException $e) {
throw new JiraException($e->getResponse()->getBody()->getContents(), $e->getCode(), $e);
}
}
}
Pattern 3 — Push writes to the queue. Never call Jira synchronously in a web request. Jira Cloud regularly takes 1–3s and rate-limits with 429. Dispatch a job:
CreateJiraIssue::dispatch($ticket)->onQueue('jira');
Then store the resulting PROJ-123 key on your local model. Retry with $tries = 5 and exponential backoff ([10, 30, 60, 120]) — this handles transient 429/5xx without special-casing.
Pattern 4 — Pagination over JQL. Search caps at 100 results per page. Loop on total:
$start = 0; $all = [];
do {
$page = $client->search('project = PROJ AND updated >= -7d', $start, 100);
$all = array_merge($all, $page['issues']);
$start += 100;
} while ($start < $page['total']);
Pattern 5 — Cache reference data. Projects, issue types, and statuses change monthly, not per-request. Cache them (Cache::remember('jira.projects', 3600, ...)) and validate incoming issue-type names against that cache before POSTing, so users get a clear 422 instead of a Jira 400 blob.
Pattern 6 — Inbound sync via webhooks. Register a Jira webhook pointing at a Laravel route, verify the shared secret, queue the payload, and reconcile by issue.key. This is far cheaper than polling JQL every minute.
guzzle/guzzle (v3, class Guzzle\Http\Client) conflicting with guzzlehttp/guzzle (v6+, class GuzzleHttp\Client). Class names changed between those majors — any copied code needs Guzzle\Http\ → GuzzleHttp\ and $client->get(...)->send() → $client->get(...).AppKernel, no services.yml auto-loading, no container tag system. Budget an hour to port the API class; do not budget a day fighting Composer --ignore-platform-reqs.email + API token. If the bundle's config only exposes username/password, feed the email into username and the token into password — the HTTP Basic header is identical, so it works, but the naming will confuse the next developer. Add a comment./rest/api/2/ accepts plain-string description. /rest/api/3/ requires Atlassian Document Format (an ADF JSON object) and will 400 on a string. If issue creation "works in Postman but not in code," check the version segment first.customfield_10011, customfield_10014 (epic link), etc. Fetch them once with /rest/api/2/field, dump the mapping to a config file, and never hardcode IDs across Cloud sites — they differ per instance.jql string interpolation is an injection vector. Pass it as a Guzzle query array (auto-encoded), and escape user-supplied values: sprintf('summary ~ "%s"', addcslashes($term, '"\\')).{"errorMessages": [], "errors": {"customfield_10011": "..."}}. Always log both keys; the per-field errors map is what tells you which field was wrong, and the top-level message is often empty.0 (infinite). One hung Jira response will pin a queue worker forever. 'timeout' => 15, 'connect_timeout' => 5.429 explicitly. Read the Retry-After header rather than a fixed sleep, and keep concurrency low — Cloud's burst limits are per-app-per-tenant, so a bulk import of 5,000 issues will get throttled even on a paid plan.parameters.yml predates .env; if you're inheriting that config, move jira_user/jira_password into environment variables and add the file to .gitignore while you're in there.MockHandler, not the live instance. new Client(['handler' => HandlerStack::create(new MockHandler([$response]))]) lets you assert on the exact fields payload and reproduce 400/429 paths in CI without a Jira sandbox.JiraClientInterface so tests and a future Jira Server-vs-Cloud split are cheap.guzzlehttp/guzzle: ^7 yourself.How can I help you explore Laravel packages today?