Work with darvis/bullhorn-api. Use it to read or write candidates, job orders, submissions, placements or any other entity of the Bullhorn REST API from a Laravel or plain PHP app, to search or query, to upload or parse a CV, to handle a rejected entity, a failed login, a 401 or a timeout, to set up the shared session, and to test code that talks to Bullhorn without calling it.
php artisan boost:add-skill darvis/bullhorn-api
Save this content to: .claude/skills/bullhorn-api-development/SKILL.md
# darvis/bullhorn-api development
## When to use this skill
Use this skill when code reads from or writes to Bullhorn in an application that has `darvis/bullhorn-api` installed, when a call throws or `php artisan bullhorn:test` fails, when you decide where the session is kept, when you move code off `jonathanraftery/bullhorn-rest-client`, or when you write tests around such code.
## The parts
| Class | What it is |
| --- | --- |
| `Darvis\BullhornApi\Client` | The client. Plain PHP. In Laravel a singleton, alias `bullhorn`, facade `Darvis\BullhornApi\Laravel\Facades\Bullhorn` |
| `Darvis\BullhornApi\Config` | Immutable settings: credentials, data center URLs, version, ttl, timeouts |
| `Darvis\BullhornApi\Exceptions\BullhornException` | Every failure: `status()`, `errorKey()`, `invalidFields()` |
| `Darvis\BullhornApi\Exceptions\AuthenticationException` | A failed login; extends `BullhornException` |
| `Darvis\BullhornApi\Http\Transport` | How a request is sent: `Http\CurlTransport` or `Laravel\HttpTransport` |
| `Darvis\BullhornApi\Token\TokenStore` | Where the session is kept: `Token\MemoryTokenStore`, `Token\FileTokenStore` or `Laravel\CacheTokenStore` |
| `Darvis\BullhornApi\Testing\FakeTransport` | A transport for tests that answers the login by itself |
## How a call runs
1. The client uses the session on its instance, or reads one from the store (key `bullhorn.session.<sha256 of client id|username>`; in Laravel the cache, encrypted).
2. Without one, it takes the lock and logs in: loginInfo (kept for a day), `oauth/authorize` (the code arrives in the `Location` of a redirect), `oauth/token` (secret in the form body), `rest-services/login`. The session goes into the store.
3. The call goes to the session's REST URL plus the path, with `BhRestToken` and `Accept: application/json`. A query array is RFC 3986 encoded, a data array is sent as JSON.
4. A 401 on a session it already had: under the lock it reads the store again. Another process's newer session is taken as it is; else it refreshes with the refresh token, and when that is refused it logs in. Then the call is repeated once.
5. A 4xx or 5xx throws. Anything else is decoded and returned.
| Situation | Result | `$e->status()` |
| --- | --- | --- |
| 2xx | the decoded array, `null` for an empty body | |
| 401 on a kept session | renewed, repeated once; a second 401 throws | `401` |
| 401 on a session made for this call | not repeated, throws | `401` |
| 400 `errors.cannotPersistEntity` | `BullhornException`, nothing saved; `invalidFields()` names the fields | `400` |
| login refused (4xx), or config incomplete | `AuthenticationException` | that status, or `0` |
| 5xx or 429 during the login | `BullhornException`, not `AuthenticationException` | that status |
| timeout, no connection | `BullhornException`: `Bullhorn API call failed. No response: ...` | `0` |
## Is Bullhorn up?
```php
use Darvis\BullhornApi\Laravel\Facades\Bullhorn;
use Illuminate\Support\Facades\Cache;
$alive = Cache::remember('bullhorn-alive', 60, fn () => Bullhorn::isAlive());
```
`isAlive()` logs in when needed and pings. It returns `false` for every `BullhornException` (wrong credentials, an outage, no connection) and never throws one. Always cache it: without a session it costs a login. For the reason, call `ping()` in a try/catch, or run `php artisan bullhorn:test`. A health check URL returns 503 when it is `false`.
## Examples
The package has thirteen commented scripts in `examples/` of its repository (not in `vendor/`) and an Examples page in its documentation with Laravel recipes: health check URL, find or create a candidate by email, a Livewire profile form with validation, open jobs with a cache, paging with a generator, a queued job that applies for a job (find or create, attach the CV with `upload('PUT', 'file/Candidate/{id}/raw', ...)` and `filetype=SAMPLE`, then a `JobSubmission` with status `New Lead` and `dateWebResponse` in milliseconds), parsing a CV with `resume/parseToCandidate` and `format=text`, adding a `Note` with `personReference` and `commentingPerson`, and tests. Point a user there before writing these from scratch.
## Pitfalls
- **PUT creates, POST updates.** `put('entity/Candidate/5', $data)` does not update candidate 5. Use `createEntity()` and `updateEntity()`.
- **Construct no client in Laravel.** `new Client(...)` gets a `MemoryTokenStore`: one login per request, and Bullhorn blocks API users that log in too often. Resolve it from the container.
- **The cache store must be shared.** `CACHE_STORE=array` in production means a login per request. A store without locks works, but two processes may then renew at once and one of them logs in again.
- **Don't encode the body.** `post($path, ['body' => json_encode($data)])` sends `{"body":"..."}`. Pass the array.
- **`fields` is required** on `entity`, `search` and `query`; the helpers default to `id`.
- **Validate before you send.** One field over Bullhorn's column length and the whole entity is refused. The column types are on Bullhorn's entity reference: https://bullhorn.github.io/rest-api-docs/entityref.html
- **Never parse the message** of an exception. Use `status()`, `errorKey()` and `invalidFields()`. Never put the message of a `BullhornException` in front of a visitor: it holds Bullhorn's whole answer.
- **No retry for a 429, 5xx or timeout** by the client. Use a queued job with `$backoff`, and look up whether a timed-out write went through before repeating it.
- **Credentials** live only in `.env`. They never belong in code, tests, a URL you build, or a log line.
## Without Laravel
```php
use Darvis\BullhornApi\Client;
use Darvis\BullhornApi\Token\FileTokenStore;
$bullhorn = Client::fromEnv(new FileTokenStore('/var/lib/myapp/bullhorn'));
```
`FileTokenStore` needs an absolute, existing, writable directory, and refuses one inside the document root of a web request. Files are `0600` and written atomically.
## Testing
Laravel: one `Http::fake()` callback that returns `FakeTransport::login($request->url())` as a response when it is not null and your API answer otherwise, with `Http::preventStrayRequests()` and the four `bullhorn.*` credentials set to any value. `Http::fake()` keeps the first callback that matches, so don't register a second one in the same test. Or mock `Darvis\BullhornApi\Client` in the container.
Plain PHP:
```php
use Darvis\BullhornApi\Client;
use Darvis\BullhornApi\Config;
use Darvis\BullhornApi\Http\Response;
use Darvis\BullhornApi\Testing\FakeTransport;
$transport = (new FakeTransport)
->respond('entity/Candidate/1', ['data' => ['id' => 1]])
->respond('ping', new Response(401), ['sessionExpires' => 1]);
$client = new Client(new Config('id', 'secret', 'user', 'password'), $transport);
```
## Moving from jonathanraftery/bullhorn-rest-client
| Old | New |
| --- | --- |
| `rawRequest('GET', 'entity/Candidate/1?fields=id')` | `entity('Candidate', 1, 'id')` |
| `rawRequest('PUT', 'entity/X', ['body' => json_encode($d)])` | `createEntity('X', $d)` |
| `rawRequest('POST', 'entity/X/1', ['body' => json_encode($d)])` | `updateEntity('X', 1, $d)` |
| `['multipart' => [['name' => 'file', 'contents' => fopen($f, 'r')]]]` | `upload('POST', $path, basename($f), file_get_contents($f), $mime)` |
| `json_decode($response->getBody())` | the return value is already an array |
| `BullhornAuthException` | `AuthenticationException` |
| `initiateSession()`, `refreshSession()`, `sessionIsValid()` | not needed |
Delete leftover `bullhorn-auth-store.json` files: they hold a session and a refresh token, and one may be in `public/`.
Use this skill when code reads from or writes to Bullhorn in an application that has darvis/bullhorn-api installed, when a call throws or php artisan bullhorn:test fails, when you decide where the session is kept, when you move code off jonathanraftery/bullhorn-rest-client, or when you write tests around such code.
| Class | What it is |
|---|---|
Darvis\BullhornApi\Client |
The client. Plain PHP. In Laravel a singleton, alias bullhorn, facade Darvis\BullhornApi\Laravel\Facades\Bullhorn |
Darvis\BullhornApi\Config |
Immutable settings: credentials, data center URLs, version, ttl, timeouts |
Darvis\BullhornApi\Exceptions\BullhornException |
Every failure: status(), errorKey(), invalidFields() |
Darvis\BullhornApi\Exceptions\AuthenticationException |
A failed login; extends BullhornException |
Darvis\BullhornApi\Http\Transport |
How a request is sent: Http\CurlTransport or Laravel\HttpTransport |
Darvis\BullhornApi\Token\TokenStore |
Where the session is kept: Token\MemoryTokenStore, Token\FileTokenStore or Laravel\CacheTokenStore |
Darvis\BullhornApi\Testing\FakeTransport |
A transport for tests that answers the login by itself |
bullhorn.session.<sha256 of client id|username>; in Laravel the cache, encrypted).oauth/authorize (the code arrives in the Location of a redirect), oauth/token (secret in the form body), rest-services/login. The session goes into the store.BhRestToken and Accept: application/json. A query array is RFC 3986 encoded, a data array is sent as JSON.| Situation | Result | $e->status() |
|---|---|---|
| 2xx | the decoded array, null for an empty body |
|
| 401 on a kept session | renewed, repeated once; a second 401 throws | 401 |
| 401 on a session made for this call | not repeated, throws | 401 |
400 errors.cannotPersistEntity |
BullhornException, nothing saved; invalidFields() names the fields |
400 |
| login refused (4xx), or config incomplete | AuthenticationException |
that status, or 0 |
| 5xx or 429 during the login | BullhornException, not AuthenticationException |
that status |
| timeout, no connection | BullhornException: Bullhorn API call failed. No response: ... |
0 |
use Darvis\BullhornApi\Laravel\Facades\Bullhorn;
use Illuminate\Support\Facades\Cache;
$alive = Cache::remember('bullhorn-alive', 60, fn () => Bullhorn::isAlive());
isAlive() logs in when needed and pings. It returns false for every BullhornException (wrong credentials, an outage, no connection) and never throws one. Always cache it: without a session it costs a login. For the reason, call ping() in a try/catch, or run php artisan bullhorn:test. A health check URL returns 503 when it is false.
The package has thirteen commented scripts in examples/ of its repository (not in vendor/) and an Examples page in its documentation with Laravel recipes: health check URL, find or create a candidate by email, a Livewire profile form with validation, open jobs with a cache, paging with a generator, a queued job that applies for a job (find or create, attach the CV with upload('PUT', 'file/Candidate/{id}/raw', ...) and filetype=SAMPLE, then a JobSubmission with status New Lead and dateWebResponse in milliseconds), parsing a CV with resume/parseToCandidate and format=text, adding a Note with personReference and commentingPerson, and tests. Point a user there before writing these from scratch.
put('entity/Candidate/5', $data) does not update candidate 5. Use createEntity() and updateEntity().new Client(...) gets a MemoryTokenStore: one login per request, and Bullhorn blocks API users that log in too often. Resolve it from the container.CACHE_STORE=array in production means a login per request. A store without locks works, but two processes may then renew at once and one of them logs in again.post($path, ['body' => json_encode($data)]) sends {"body":"..."}. Pass the array.fields is required on entity, search and query; the helpers default to id.status(), errorKey() and invalidFields(). Never put the message of a BullhornException in front of a visitor: it holds Bullhorn's whole answer.$backoff, and look up whether a timed-out write went through before repeating it..env. They never belong in code, tests, a URL you build, or a log line.use Darvis\BullhornApi\Client;
use Darvis\BullhornApi\Token\FileTokenStore;
$bullhorn = Client::fromEnv(new FileTokenStore('/var/lib/myapp/bullhorn'));
FileTokenStore needs an absolute, existing, writable directory, and refuses one inside the document root of a web request. Files are 0600 and written atomically.
Laravel: one Http::fake() callback that returns FakeTransport::login($request->url()) as a response when it is not null and your API answer otherwise, with Http::preventStrayRequests() and the four bullhorn.* credentials set to any value. Http::fake() keeps the first callback that matches, so don't register a second one in the same test. Or mock Darvis\BullhornApi\Client in the container.
Plain PHP:
use Darvis\BullhornApi\Client;
use Darvis\BullhornApi\Config;
use Darvis\BullhornApi\Http\Response;
use Darvis\BullhornApi\Testing\FakeTransport;
$transport = (new FakeTransport)
->respond('entity/Candidate/1', ['data' => ['id' => 1]])
->respond('ping', new Response(401), ['sessionExpires' => 1]);
$client = new Client(new Config('id', 'secret', 'user', 'password'), $transport);
| Old | New |
|---|---|
rawRequest('GET', 'entity/Candidate/1?fields=id') |
entity('Candidate', 1, 'id') |
rawRequest('PUT', 'entity/X', ['body' => json_encode($d)]) |
createEntity('X', $d) |
rawRequest('POST', 'entity/X/1', ['body' => json_encode($d)]) |
updateEntity('X', 1, $d) |
['multipart' => [['name' => 'file', 'contents' => fopen($f, 'r')]]] |
upload('POST', $path, basename($f), file_get_contents($f), $mime) |
json_decode($response->getBody()) |
the return value is already an array |
BullhornAuthException |
AuthenticationException |
initiateSession(), refreshSession(), sessionIsValid() |
not needed |
Delete leftover bullhorn-auth-store.json files: they hold a session and a refresh token, and one may be in public/.
How can I help you explore Laravel packages today?