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
bullhorn-api-development
bullhorn-api-development
darvis/bullhorn-api
claude-code
php
oauth2
crm
laravel
API-Client
ATS
bullhorn
recruitment
laravel-boost
bullhorn-rest-api
staffing

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.

Install
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/`.

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?

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

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:

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/.

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.
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox