Examples

Each recipe below is complete: copy it, change the names, and it works. They use the Bullhorn facade; in a class you can also inject Darvis\BullhornApi\Client, which is the same object.

Prefer to try things on the command line first? The examples folder has thirteen small scripts that run without Laravel, starting with php examples/01-is-bullhorn-alive.php.

A few things to know before you start:

  • Every method returns Bullhorn’s answer as a PHP array. The result is almost always in ['data']; a new id is in ['changedEntityId'].
  • Bullhorn uses PUT to create and POST to update. createEntity() and updateEntity() choose the right one for you.
  • Ask for the fields you need: ['id', 'firstName', 'email']. Bullhorn returns nothing you did not ask for.
  • Everything that goes wrong throws a BullhornException. A failed login throws an AuthenticationException, which you catch first.

Is Bullhorn alive?

isAlive() answers true or false and never throws. It checks everything at once: your credentials, the login and the API.

use Darvis\BullhornApi\Laravel\Facades\Bullhorn;

if (Bullhorn::isAlive()) {
    // Show the vacancies, accept the application ...
} else {
    // Show "we are working on it" instead of an error page.
}

Without a session it logs in first, and Bullhorn limits how often you may log in. So don’t ask on every page view; keep the answer for a minute:

use Illuminate\Support\Facades\Cache;

$alive = Cache::remember('bullhorn-alive', 60, fn () => Bullhorn::isAlive());

A health check URL

For an uptime monitor such as Oh Dear or UptimeRobot. Add it to routes/web.php:

use Darvis\BullhornApi\Laravel\Facades\Bullhorn;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;

Route::get('/health/bullhorn', function () {
    $alive = Cache::remember('bullhorn-alive', 60, fn () => Bullhorn::isAlive());

    return response()->json(['bullhorn' => $alive ? 'up' : 'down'], $alive ? 200 : 503);
});

Get a message when it goes down

In routes/console.php. The scheduler must run, see php artisan schedule:work.

use Darvis\BullhornApi\Exceptions\BullhornException;
use Darvis\BullhornApi\Laravel\Facades\Bullhorn;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schedule;

Schedule::call(function () {
    try {
        Bullhorn::ping();
    } catch (BullhornException $e) {
        // ping() tells you why, isAlive() only that it failed.
        Log::error('Bullhorn is down: '.$e->getMessage());
    }
})->everyFifteenMinutes();

Read a candidate

use Darvis\BullhornApi\Exceptions\BullhornException;
use Darvis\BullhornApi\Laravel\Facades\Bullhorn;

public function show(int $id)
{
    try {
        $candidate = Bullhorn::entity('Candidate', $id, ['id', 'firstName', 'lastName', 'email', 'mobile'])['data'];
    } catch (BullhornException $e) {
        abort($e->status() === 404 ? 404 : 503);
    }

    return view('candidates.show', ['candidate' => $candidate]);
}

Find a candidate, or create one

The most common task on a website: someone fills in a form, and you need their candidate in Bullhorn. Put it in a small class, so every form uses the same code. app/Services/CandidateFinder.php:

namespace App\Services;

use Darvis\BullhornApi\Client;

class CandidateFinder
{
    public function __construct(private Client $bullhorn) {}

    /**
     * The id of the candidate with this email address. A new candidate when there is none.
     */
    public function findOrCreate(string $email, string $firstName, string $lastName): int
    {
        $found = $this->bullhorn->search(
            'Candidate',
            'email:"'.str_replace('"', '', $email).'" AND isDeleted:0',
            'id',
            ['count' => 1],
        );

        if ($found['data'] !== []) {
            return $found['data'][0]['id'];
        }

        return $this->bullhorn->createEntity('Candidate', [
            'firstName' => $firstName,
            'lastName' => $lastName,
            'name' => $firstName.' '.$lastName, // Bullhorn does not fill this in by itself
            'email' => $email,
        ])['changedEntityId'];
    }
}

Use it anywhere with app(CandidateFinder::class)->findOrCreate(...), or as a parameter of a controller method.

Update a candidate from a form

A Livewire component that saves a profile. Validate first: Bullhorn refuses the whole update when one field is wrong, and then nothing is saved.

namespace App\Livewire;

use Darvis\BullhornApi\Exceptions\AuthenticationException;
use Darvis\BullhornApi\Exceptions\BullhornException;
use Darvis\BullhornApi\Laravel\Facades\Bullhorn;
use Livewire\Component;

class CandidateProfile extends Component
{
    public int $candidateId;

    public string $mobile = '';

    public string $customText1 = '';

    public function mount(int $candidateId): void
    {
        $this->candidateId = $candidateId;

        $candidate = Bullhorn::entity('Candidate', $candidateId, ['mobile', 'customText1'])['data'];

        $this->mobile = $candidate['mobile'] ?? '';
        $this->customText1 = $candidate['customText1'] ?? '';
    }

    public function save(): void
    {
        $data = $this->validate([
            'mobile' => ['nullable', 'string', 'max:20'],
            'customText1' => ['nullable', 'string', 'max:100'], // customText fields hold 100 characters
        ]);

        try {
            Bullhorn::updateEntity('Candidate', $this->candidateId, $data);
        } catch (AuthenticationException $e) {
            report($e);
            $this->addError('form', 'We cannot reach our system right now. Please try again later.');

            return;
        } catch (BullhornException $e) {
            report($e);

            // Bullhorn says which fields it refused.
            foreach ($e->invalidFields() as $field) {
                $this->addError($field, 'This value was not accepted.');
            }

            return;
        }

        session()->flash('status', 'Your profile is saved.');
    }

    public function render()
    {
        return view('livewire.candidate-profile');
    }
}

resources/views/livewire/candidate-profile.blade.php:

<form wire:submit="save">
    @error('form') <p class="error">{{ $message }}</p> @enderror

    <label>Mobile <input type="text" wire:model="mobile" maxlength="20"></label>
    @error('mobile') <p class="error">{{ $message }}</p> @enderror

    <label>Notes <textarea wire:model="customText1" maxlength="100"></textarea></label>
    @error('customText1') <p class="error">{{ $message }}</p> @enderror

    <button type="submit">Save</button>
</form>

Use maxlength on a textarea; max does nothing there.

Show the open jobs on your website

Jobs are JobOrders. Keep the list for ten minutes, so a busy website does not ask Bullhorn on every visit.

use Darvis\BullhornApi\Laravel\Facades\Bullhorn;
use Illuminate\Support\Facades\Cache;

public function index()
{
    $jobs = Cache::remember('open-jobs', 600, fn () => Bullhorn::search(
        'JobOrder',
        'isOpen:1 AND isDeleted:0',
        ['id', 'title', 'employmentType', 'address', 'dateAdded'],
        ['sort' => '-dateAdded', 'count' => 50],
    )['data']);

    return view('jobs.index', ['jobs' => $jobs]);
}
@foreach ($jobs as $job)
    <article>
        <h2>{{ $job['title'] }}</h2>
        <p>{{ $job['employmentType'] }} in {{ $job['address']['city'] ?? 'the Netherlands' }}</p>
        {{-- Bullhorn dates are milliseconds since 1970 --}}
        <p>Posted {{ \Illuminate\Support\Carbon::createFromTimestampMs($job['dateAdded'])->diffForHumans() }}</p>
    </article>
@endforeach

All results, page by page

Bullhorn returns at most a page at a time. This generator asks for the next page while you loop, so you never hold everything in memory:

use Darvis\BullhornApi\Laravel\Facades\Bullhorn;

/**
 * @return \Generator<int, array<string, mixed>>
 */
function searchAll(string $type, string $query, array $fields): \Generator
{
    $start = 0;

    do {
        $page = Bullhorn::search($type, $query, $fields, ['start' => $start, 'count' => 200, 'sort' => 'id']);

        yield from $page['data'];

        $start += 200;
    } while (count($page['data']) === 200); // a page that is not full is the last one
}

foreach (searchAll('JobOrder', 'isOpen:1 AND isDeleted:0', ['id', 'title']) as $job) {
    // One job at a time.
}

Apply for a job, in the background

An application touches Bullhorn three times: find or create the candidate, attach the CV, create the submission. Do it in a queued job, so the visitor does not wait, and a short outage of Bullhorn is retried later instead of losing the application.

namespace App\Jobs;

use App\Services\CandidateFinder;
use Darvis\BullhornApi\Client;
use Darvis\BullhornApi\Exceptions\AuthenticationException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Storage;

class SendApplicationToBullhorn implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;

    public array $backoff = [60, 300, 900, 3600];

    public function __construct(
        public string $email,
        public string $firstName,
        public string $lastName,
        public int $jobId,
        public string $cvPath, // a path on the local disk, stored by the form
    ) {}

    public function handle(Client $bullhorn, CandidateFinder $finder): void
    {
        $candidateId = $finder->findOrCreate($this->email, $this->firstName, $this->lastName);

        // The CV on the candidate's Files tab.
        $bullhorn->upload(
            'PUT',
            'file/Candidate/'.$candidateId.'/raw',
            basename($this->cvPath),
            Storage::get($this->cvPath),
            Storage::mimeType($this->cvPath) ?: 'application/octet-stream',
            ['externalID' => 'cv', 'filetype' => 'SAMPLE'],
        );

        // The application itself: a "web response" in Bullhorn's words.
        $bullhorn->createEntity('JobSubmission', [
            'candidate' => ['id' => $candidateId],
            'jobOrder' => ['id' => $this->jobId],
            'status' => 'New Lead',
            'dateWebResponse' => now()->getTimestampMs(),
        ]);

        Storage::delete($this->cvPath);
    }

    public function failed(\Throwable $e): void
    {
        // After the last try. A wrong password will not fix itself: tell someone.
        if ($e instanceof AuthenticationException) {
            report($e);
        }
    }
}

Start it from your form with SendApplicationToBullhorn::dispatch($email, $firstName, $lastName, $jobId, $path);. When a try fails halfway, the next try finds the candidate it already created, so you don’t get duplicates.

Read a CV to fill in a form

Bullhorn’s parser reads a PDF or Word file and returns what it found. It saves nothing, so you can show the result to the visitor first.

namespace App\Livewire;

use Darvis\BullhornApi\Exceptions\BullhornException;
use Darvis\BullhornApi\Laravel\Facades\Bullhorn;
use Livewire\Component;
use Livewire\WithFileUploads;

class ApplyWithCv extends Component
{
    use WithFileUploads;

    public $cv;

    public string $firstName = '';

    public string $lastName = '';

    public string $email = '';

    public function updatedCv(): void
    {
        $this->validate(['cv' => ['file', 'mimes:pdf,doc,docx', 'max:5120']]);

        try {
            $parsed = Bullhorn::upload(
                'POST',
                'resume/parseToCandidate',
                $this->cv->getClientOriginalName(),
                file_get_contents($this->cv->getRealPath()),
                $this->cv->getMimeType(),
                ['format' => 'text'],
            );
        } catch (BullhornException $e) {
            report($e);

            return; // The visitor fills in the form by hand.
        }

        $this->firstName = $parsed['candidate']['firstName'] ?? $this->firstName;
        $this->lastName = $parsed['candidate']['lastName'] ?? $this->lastName;
        $this->email = $parsed['candidate']['email'] ?? $this->email;
    }

    public function render()
    {
        return view('livewire.apply-with-cv');
    }
}

Add a note to a candidate

A note needs who it is about (personReference) and who wrote it (commentingPerson: the id of a recruiter, a CorporateUser in Bullhorn). With both, it appears on the candidate’s Notes tab. action must be one of the actions set up in your Bullhorn; Bullhorn::get('settings/commentActionList') lists them.

use Darvis\BullhornApi\Laravel\Facades\Bullhorn;

Bullhorn::createEntity('Note', [
    'action' => 'Other',
    'comments' => 'Applied through the website for '.$jobTitle.'.',
    'personReference' => ['id' => $candidateId],
    'commentingPerson' => ['id' => config('services.bullhorn.recruiter_id')],
]);

Which fields are there?

Every Bullhorn is set up differently: customText1 is “Driving licence” in one and “Notice period” in another. meta() tells you what yours has. Try it in php artisan tinker:

collect(Bullhorn::meta('Candidate')['fields'])
    ->map(fn ($field) => [$field['name'], $field['label'] ?? '', $field['maxLength'] ?? ''])
    ->all();

Test your code

Never let a test reach Bullhorn. Two ways, from simple to thorough.

Mock the client when the test is about your screen, not about Bullhorn:

use Darvis\BullhornApi\Client;
use Livewire\Livewire;

test('the profile shows the mobile number from Bullhorn', function () {
    $this->mock(Client::class)
        ->shouldReceive('entity')
        ->andReturn(['data' => ['mobile' => '0612345678', 'customText1' => '']]);

    Livewire::test(\App\Livewire\CandidateProfile::class, ['candidateId' => 1])
        ->assertSet('mobile', '0612345678');
});

Fake HTTP when the test is about what you send to Bullhorn. The login answers itself through FakeTransport::login():

use Darvis\BullhornApi\Testing\FakeTransport;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;

test('an application creates a submission in Bullhorn', function () {
    config(['bullhorn.client_id' => 'id', 'bullhorn.client_secret' => 'secret', 'bullhorn.username' => 'user', 'bullhorn.password' => 'password']);

    Http::preventStrayRequests();
    Http::fake(function (Request $request) {
        $login = FakeTransport::login($request->url());

        if ($login !== null) {
            return Http::response($login->body, $login->status, $login->headers);
        }

        return Http::response(['data' => [], 'total' => 0, 'changedEntityId' => 7, 'fileId' => 3]);
    });

    // ... dispatch the job synchronously, or submit the form ...

    Http::assertSent(fn (Request $request) => $request->method() === 'PUT'
        && str_ends_with($request->url(), 'entity/JobSubmission')
        && $request['status'] === 'New Lead');
});

More on both in Testing.