This tutorial sends a resume file from PHP to the HireLayer resume parsing API and turns the response into typed PHP objects. It uses Guzzle for the HTTP call, PHP 8.2 readonly classes for the data, and the API’s documented error codes to decide what to retry and what to show the candidate. A Laravel version and a plain cURL version follow at the end.
If your backend runs Python or Node.js, the Python and Node.js guide covers the same endpoint. There are also versions for C# and .NET and Java.
What you need
- PHP 8.2 or later with the
curlandjsonextensions. The models use readonly classes, which arrived in 8.2. - Guzzle 7, the HTTP client most PHP frameworks already ship with.
- An API key from the dashboard, stored in the
HIRELAYER_API_KEYenvironment variable of the server that makes the call. Never put it in JavaScript sent to the browser or in a public repository. - A test resume. Use your own CV or a synthetic one; the live demo shows the JSON you should expect before you write any code.
Install Guzzle with Composer if your project does not have it yet:
composer require guzzlehttp/guzzle:^7.8
The request the API expects
Everything below comes from the CV Extract V3 reference. One request parses one file and returns the result in the same response.
| Item | Value |
|---|---|
| Endpoint | POST https://hirelayer.co/api/v3/parser |
| Authentication | X-API-Key header. Authorization: Bearer is
not accepted. |
| Body | multipart/form-data with a required file
part, plus optional application_id (your own reference,
echoed back), do_not_store_data (true or
false) and webhook_url. |
| Formats | PDF, DOC, DOCX, ODT, PPT, PPTX, ODP, XLS, RTF, TXT, JPG/JPEG, PNG and BMP. The type is detected from the content. |
| Size | Encoded upload up to 6 MiB, which means a file of about 4.5 MB. Up to 100,000 extracted characters. |
| Timing | Usually about 35 seconds, longer for scans. The API answers
504 after 145 seconds, so use a client timeout of at
least 150 seconds. |
| Cost | 1 credit per successful parse (HTTP 200). Failed calls
are free. |
do_not_store_data=true tells the API not to keep the resume file.
The response then has info_resume.url set to null,
and the cropped candidate photo in info_resume.face_url is a
temporary link that expires 10 minutes after parsing, at
info_resume.face_url_expires_at. Download the photo straight away
if you need it. The default is false, which stores the file. The
examples here send true.
Parse a resume with Guzzle
The shortest working version reads resume.pdf, sends it as the
file part, and prints two fields. Guzzle builds the multipart
body and its boundary when you pass the multipart option, so you
never set Content-Type yourself.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Utils;
$apiKey = getenv('HIRELAYER_API_KEY') ?: throw new RuntimeException('HIRELAYER_API_KEY is not set');
$http = new Client([
'base_uri' => 'https://hirelayer.co',
'timeout' => 150, // the API answers or gives up within 145 s
'connect_timeout' => 10,
'http_errors' => false, // read 4xx/5xx bodies yourself
]);
$response = $http->post('/api/v3/parser', [
'headers' => [
'X-API-Key' => $apiKey,
'Accept' => 'application/json',
],
'multipart' => [
[
'name' => 'file',
'contents' => Utils::tryFopen('resume.pdf', 'rb'),
'filename' => 'resume.pdf',
],
['name' => 'application_id', 'contents' => 'candidate-123'],
['name' => 'do_not_store_data', 'contents' => 'true'],
],
]);
$data = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
if ($response->getStatusCode() !== 200) {
throw new RuntimeException(sprintf('HTTP %d: %s', $response->getStatusCode(), $data['error'] ?? 'unknown error'));
}
echo $data['info_candidate']['full_name'] ?? '(no name found)', PHP_EOL;
echo count($data['work_experiences']), ' positions', PHP_EOL;
Two settings matter more than they look. timeout defaults to
0 in Guzzle, which waits forever; 150 seconds matches the API’s
own limit. http_errors => false stops Guzzle from throwing on
4xx and 5xx, so you can read the JSON error body and
its code instead of parsing an exception message.
Map the JSON to readonly classes
Passing the decoded array around works for a script. In an application, a
small set of value objects gives you autocompletion, static analysis and one
place to handle missing values. Most fields can be null when the
resume does not contain them, so the types are nullable and every read has a
default.
<?php
declare(strict_types=1);
namespace App\HireLayer;
use DateTimeImmutable;
final readonly class ParsedResume
{
/**
* @param list<string> $warnings
* @param list<WorkExperience> $workExperiences
* @param list<Education> $educations
* @param list<Language> $languages
* @param list<Skill> $skills
*/
public function __construct(
public string $requestId,
public bool $partial,
public array $warnings,
public ?string $applicationId,
public ?string $fileUrl,
public Candidate $candidate,
public array $workExperiences,
public array $educations,
public array $languages,
public array $skills,
) {}
/** @param array<string, mixed> $data Decoded body of a 200 response */
public static function fromArray(array $data): self
{
return new self(
requestId: $data['request_id'],
partial: ($data['upstream_status'] ?? null) === 'partial',
warnings: $data['warnings'] ?? [],
applicationId: $data['info_resume']['application_id'] ?? null,
fileUrl: $data['info_resume']['url'] ?? null,
candidate: Candidate::fromArray($data['info_candidate'] ?? []),
workExperiences: array_map(WorkExperience::fromArray(...), $data['work_experiences'] ?? []),
educations: array_map(Education::fromArray(...), $data['educations'] ?? []),
languages: array_map(Language::fromArray(...), $data['languages'] ?? []),
skills: array_map(Skill::fromArray(...), $data['skills'] ?? []),
);
}
}
final readonly class Candidate
{
public function __construct(
public ?string $fullName,
public ?string $firstName,
public ?string $lastName,
public ?string $email,
public ?string $phoneNumber,
public ?string $jobTitle,
public ?string $experienceLevel,
public ?string $linkedinUrl,
public ?string $city,
public ?string $countryCode,
) {}
/** @param array<string, mixed> $c */
public static function fromArray(array $c): self
{
return new self(
fullName: $c['full_name'] ?? null,
firstName: $c['first_name'] ?? null,
lastName: $c['last_name'] ?? null,
email: $c['email'] ?? null,
phoneNumber: $c['phone_number'] ?? null,
jobTitle: $c['job_title'] ?? null,
experienceLevel: $c['experience_level'] ?? null,
linkedinUrl: $c['linkedin_url'] ?? null,
city: $c['location']['city'] ?? null,
countryCode: $c['location']['country_code'] ?? null,
);
}
}
final readonly class WorkExperience
{
public function __construct(
public ?string $companyName,
public ?string $jobTitle,
public ?string $contractType,
public ?DateTimeImmutable $startDate,
public ?DateTimeImmutable $endDate,
public bool $current,
public ?string $description,
) {}
/** @param array<string, mixed> $w */
public static function fromArray(array $w): self
{
return new self(
companyName: $w['company_name'] ?? null,
jobTitle: $w['job_title'] ?? null,
contractType: $w['contract_type'] ?? null,
startDate: toDate($w['start_date'] ?? null),
endDate: toDate($w['end_date'] ?? null),
current: $w['currently_active'] ?? false,
description: $w['description'] ?? null,
);
}
}
final readonly class Education
{
public function __construct(
public ?string $degreeTitle,
public ?string $schoolName,
public ?string $degreeType,
public ?DateTimeImmutable $startDate,
public ?DateTimeImmutable $endDate,
) {}
/** @param array<string, mixed> $e */
public static function fromArray(array $e): self
{
return new self(
degreeTitle: $e['degree_title'] ?? null,
schoolName: $e['school_name'] ?? null,
degreeType: $e['degree_type'] ?? null,
startDate: toDate($e['start_date'] ?? null),
endDate: toDate($e['end_date'] ?? null),
);
}
}
final readonly class Language
{
public function __construct(public string $name, public ?string $level) {}
/** @param array<string, mixed> $l */
public static function fromArray(array $l): self
{
return new self($l['language'], $l['level'] ?? null);
}
}
final readonly class Skill
{
public function __construct(public string $title, public string $type, public bool $normalized) {}
/** @param array<string, mixed> $s */
public static function fromArray(array $s): self
{
return new self($s['skill_title'], $s['skill_type'], $s['status'] === 'normalized');
}
}
/** Dates are YYYY-MM-DD; "!" sets the time to midnight. */
function toDate(?string $value): ?DateTimeImmutable
{
return $value === null ? null : (DateTimeImmutable::createFromFormat('!Y-m-d', $value) ?: null);
}
The classes only keep the fields this example needs; add others from the response schema the same way, such as info_candidate.birth_date,
educations[].location, certifications or
rome_jobs. A few details are worth knowing:
- Dates are
YYYY-MM-DDstrings. In work history, a year on its own becomes January 1 for a start date and December 31 for an end date, so avoid showing “since January” when the resume only gave a year. end_dateisnullfor an ongoing role, andcurrently_activesays so explicitly.contract_type,experience_level,education_leveland languageleveluse fixed English values, which makes them safe to turn into PHP enums.educations[].degree_typeis not normalized: treat unknown values asOther.- Free text stays in the resume’s language. Skills with
status: normalizeduse French taxonomy labels; skills withstatus: rawkeep the resume’s wording.
A reusable client with retries
The next class wraps the call: it checks the file size before uploading, turns
non-200 responses into a typed exception, and retries the
failures that are worth retrying with Guzzle’s own retry middleware.
<?php
declare(strict_types=1);
namespace App\HireLayer;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Utils;
use InvalidArgumentException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use RuntimeException;
use Throwable;
final class HireLayerException extends RuntimeException
{
public function __construct(
string $message,
public readonly int $status,
public readonly ?string $errorCode = null,
public readonly ?string $requestId = null,
) {
parent::__construct($message, $status);
}
}
final class HireLayerClient
{
private const RETRYABLE_STATUSES = [429, 500, 502, 503, 504];
private const MAX_FILE_BYTES = 4_500_000;
private readonly Client $http;
public function __construct(
string $apiKey,
string $baseUri = 'https://hirelayer.co',
private readonly int $maxRetries = 3,
) {
$stack = HandlerStack::create();
$stack->push(Middleware::retry($this->shouldRetry(...), $this->retryDelay(...)));
$this->http = new Client([
'base_uri' => $baseUri,
'handler' => $stack,
'timeout' => 150,
'connect_timeout' => 10,
'http_errors' => false,
'headers' => ['X-API-Key' => $apiKey, 'Accept' => 'application/json'],
]);
}
public function parse(string $path, ?string $applicationId = null, bool $doNotStoreData = true): ParsedResume
{
$size = filesize($path);
if ($size === false || $size === 0 || $size > self::MAX_FILE_BYTES) {
throw new InvalidArgumentException("$path is empty, unreadable or larger than 4.5 MB");
}
$multipart = [
['name' => 'file', 'contents' => Utils::tryFopen($path, 'rb'), 'filename' => basename($path)],
['name' => 'do_not_store_data', 'contents' => $doNotStoreData ? 'true' : 'false'],
];
if ($applicationId !== null) {
$multipart[] = ['name' => 'application_id', 'contents' => $applicationId];
}
$response = $this->http->post('/api/v3/parser', ['multipart' => $multipart]);
$status = $response->getStatusCode();
$body = json_decode((string) $response->getBody(), true);
if ($status === 200 && is_array($body)) {
return ParsedResume::fromArray($body);
}
throw new HireLayerException(
is_array($body) && is_string($body['error'] ?? null) ? $body['error'] : "HTTP $status",
$status,
is_array($body) ? ($body['code'] ?? null) : null,
$response->getHeaderLine('x-parser-request-id') ?: null,
);
}
private function shouldRetry(
int $retries,
RequestInterface $request,
?ResponseInterface $response = null,
?Throwable $error = null,
): bool {
if ($retries >= $this->maxRetries) {
return false;
}
if ($response !== null) {
return in_array($response->getStatusCode(), self::RETRYABLE_STATUSES, true);
}
// Retry only when no connection was made. After a timeout the parse
// may still complete and be charged, so let the caller decide.
$errno = $error instanceof ConnectException ? ($error->getHandlerContext()['errno'] ?? null) : null;
return in_array($errno, [CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_CONNECT], true);
}
private function retryDelay(int $retries, ?ResponseInterface $response = null): int
{
$retryAfter = $response?->getHeaderLine('Retry-After') ?? '';
if (ctype_digit($retryAfter)) {
return (int) $retryAfter * 1000;
}
// 1 s, 2 s, 4 s… plus up to 250 ms of jitter, in milliseconds.
return 1000 * 2 ** ($retries - 1) + random_int(0, 250);
}
}
The retry rules follow the errors and retries guide:
- Retry
500,502,503and504with exponential backoff, and use theRetry-Afterheader when the response has one. Failed calls are not charged. - Never retry a
4xxunchanged. The same file and the same key will fail the same way. - Do not retry after your own timeout. There is no idempotency key, and a request your client abandons can still finish and be charged. That is why the client only retries transport errors where the connection was never made (DNS failure, connection refused).
- About
429: HireLayer does not enforce a per-second rate limit, so429is not in its error catalogue. The client still treats it as retryable in case a proxy or gateway on your side returns one.
Retrying is safe here because the multipart body is built from a file handle, which Guzzle rewinds before each attempt. If you build the body from a non-seekable stream instead, read the file into a string first.
Check the response shape before you map it
Upload a resume in the live demo to see the JSON your PHP classes will receive, then use the API reference for every field and error code.
Handle each error status
Error responses are JSON with an error message, plus a
machine-readable code for file and parsing errors. They also
carry an x-parser-request-id header; log it, because support can
trace a request from it. Successful responses carry the same identifier as
request_id in the body.
| Status | What it means | What to do |
|---|---|---|
| 400 | No file part, application_id sent as a file,
or INVALID_FILE: an empty or unsupported file, or a
do_not_store_data value other than true or false. | Fix the request or ask for another file. |
| 401 | Missing API Key or Invalid API Key
(malformed, unknown or revoked). | Fix the key and alert whoever owns it. |
| 403 | Insufficient credits available. | Pause sending, top up, then retry. |
| 413 | The encoded upload is over 6 MiB. | Ask for a file under 4.5 MB. |
| 415 | The body is not multipart/form-data. | Fix the request. |
| 422 | DOCUMENT_NOT_A_RESUME, DOCUMENT_TEXT_EMPTY,
DOCUMENT_UNREADABLE or DOCUMENT_TOO_LARGE. | Show the candidate a clear message. |
| 500, 502, 503, 504 | A temporary failure, including PARSER_UNAVAILABLE and
parsing that did not finish within 145 seconds. | Retry with backoff. |
If you expected a 402 Payment Required for an empty balance: this
API uses 403 with Insufficient credits available
instead. Here is the client in use, with each case mapped to an action:
<?php
declare(strict_types=1);
use App\HireLayer\HireLayerClient;
use App\HireLayer\HireLayerException;
$client = new HireLayerClient(getenv('HIRELAYER_API_KEY') ?: throw new RuntimeException('HIRELAYER_API_KEY is not set'));
try {
$resume = $client->parse('/var/uploads/resume.pdf', applicationId: 'candidate-123');
} catch (HireLayerException $e) {
$action = match (true) {
$e->status === 401 => 'Check HIRELAYER_API_KEY: it is missing, invalid or revoked.',
$e->status === 403 => 'Out of credits: pause the queue and top up before retrying.',
$e->status === 413 => 'File too large: ask for a file under 4.5 MB.',
$e->status === 422 => match ($e->errorCode) {
'DOCUMENT_NOT_A_RESUME' => 'This document does not look like a resume.',
'DOCUMENT_TEXT_EMPTY' => 'No readable text, even with OCR: ask for another file.',
'DOCUMENT_UNREADABLE' => 'The file is corrupted or cannot be opened.',
'DOCUMENT_TOO_LARGE' => 'Too much text: ask for a shorter version.',
default => $e->getMessage(),
},
$e->errorCode === 'INVALID_FILE' => 'Empty or unsupported file: ask for another one.',
$e->status === 400, $e->status === 415 => 'Bug in the request: fix the multipart body.',
default => 'Still failing after retries: try again later.',
};
error_log(sprintf(
'HireLayer %d %s (request %s): %s',
$e->status,
$e->errorCode ?? '-',
$e->requestId ?? '-',
$e->getMessage(),
));
throw new RuntimeException($action, previous: $e);
}
if ($resume->partial) {
// The data is usable. Warnings are human-readable notes: log them, don't parse them.
error_log("Partial parse {$resume->requestId}: " . implode(' | ', $resume->warnings));
}
foreach ($resume->workExperiences as $job) {
printf(
"%s at %s, from %s\n",
$job->jobTitle ?? 'Unknown title',
$job->companyName ?? 'unknown employer',
$job->startDate?->format('M Y') ?? 'unknown date',
);
}
A 200 can still be partial. When an optional step such as OCR of
some pages, the photo, geocoding or occupation codes is skipped, the response
has upstream_status: "partial" and a human-readable note in
warnings. The data you received is usable; keep the warnings for
review rather than parsing their text.
Use it from Laravel
In Laravel, register the client once and inject it where you need it. The key
lives in .env and is read through
config/services.php, so it still works when the configuration is
cached.
<?php
namespace App\Providers;
use App\HireLayer\HireLayerClient;
use Illuminate\Support\ServiceProvider;
use RuntimeException;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// config/services.php: 'hirelayer' => ['key' => env('HIRELAYER_API_KEY')]
$this->app->singleton(HireLayerClient::class, fn (): HireLayerClient => new HireLayerClient(
config('services.hirelayer.key') ?? throw new RuntimeException('HIRELAYER_API_KEY is not set'),
));
}
}
Run the parse in a queued job, not in the upload request. A parse takes tens
of seconds, longer than the default 60-second
fastcgi_read_timeout of a typical nginx and PHP-FPM setup. Give
the job a $timeout above 150 seconds and make sure the queue
connection’s retry_after is larger than that timeout. Otherwise
the worker can pick the same job up twice while the first attempt is still
running, and you pay for two parses. For large imports, the bulk parsing pipeline guide covers queues, concurrency and duplicate protection.
If you prefer Laravel’s HTTP client to a dedicated class, the same request
looks like this. attach() sends the file part, the array passed
to post() becomes the other form fields, and retry()
takes the backoff delays in milliseconds:
<?php
use App\HireLayer\ParsedResume;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
$response = Http::withHeaders(['X-API-Key' => config('services.hirelayer.key')])
->acceptJson()
->connectTimeout(10)
->timeout(150)
->retry(
[1000, 2000, 4000],
when: fn (Throwable $e): bool => $e instanceof RequestException
&& in_array($e->response->status(), [429, 500, 502, 503, 504], true),
throw: false,
)
->attach('file', file_get_contents($path), 'resume.pdf')
->post('https://hirelayer.co/api/v3/parser', [
'application_id' => (string) $application->id,
'do_not_store_data' => 'true',
]);
if ($response->failed()) {
// Same rules as above: $response->status(), $response->json('error'),
// $response->json('code') and $response->header('x-parser-request-id').
}
$resume = ParsedResume::fromArray($response->json());
The fixed delays ignore the Retry-After header, which the Guzzle
client above honours. Either way, keep the job timeout rules from the previous
paragraph.
Without Guzzle: plain cURL
On a host where you cannot add Composer packages, the cURL extension sends the
same request. Passing an array that contains a CURLFile to
CURLOPT_POSTFIELDS makes cURL build a
multipart/form-data body with the right boundary. Do not pass the
array through http_build_query(): that produces a URL-encoded
body and a 415.
<?php
declare(strict_types=1);
$ch = curl_init('https://hirelayer.co/api/v3/parser');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . getenv('HIRELAYER_API_KEY'),
'Accept: application/json',
],
CURLOPT_POSTFIELDS => [
'file' => new CURLFile('resume.pdf', 'application/pdf', 'resume.pdf'),
'do_not_store_data' => 'true',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 150,
]);
$body = curl_exec($ch);
if ($body === false) {
throw new RuntimeException('Transport error: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data = json_decode($body, true);
if ($status !== 200) {
throw new RuntimeException(sprintf('HTTP %d: %s', $status, $data['error'] ?? 'unknown error'));
}
You then need your own retry loop around curl_exec(), with the
same rules as above.
Production checklist
- The key is read from the environment on the server and never logged or sent to the browser. Use one key per environment so you can revoke one without touching the others.
- Uploads are checked for size (about 4.5 MB) and type before you spend a request on them.
- The client timeout is at least 150 seconds, and anything in front of the worker (PHP-FPM, nginx, queue timeouts) allows that long.
- Only
5xxresponses, and connections that were never made, are retried, with backoff. - Every failure is logged with its status,
codeandx-parser-request-id, never with the resume contents. do_not_store_datamatches your retention policy, and your own storage of uploads and results has its own deletion rule.- You have checked results on a sample of your real resumes. The parsing accuracy guide explains how to score them field by field.
Frequently asked questions
Can I call the resume parser API from JavaScript in the browser?
Not with your API key: anyone could read it from the page. Upload the file to your PHP backend and call the API from there.
Which PHP versions does this code support?
The readonly classes need PHP 8.2 or later. On PHP 8.1, drop the
readonly keyword from the class declarations and mark each
promoted property readonly instead.
Can I send several resumes in one request?
No. One request parses one file. To process a batch, queue one job per file and run a few of them in parallel.
Do I have to send the original file name or MIME type?
The API detects the format from the content, so a generic name such as
resume.pdf is fine. Keep candidate names out of file names
you send if you do not need them.
What happens if the parse takes too long?
After 145 seconds the API returns 504 with
PARSER_UNAVAILABLE, which is safe to retry. A client timeout
of at least 150 seconds makes sure you see that response rather than
abandoning a request that might still be charged.
Sources and further reading
- HireLayer API documentation: Parse a resume (V3)
- HireLayer API documentation: Errors and retries
- Guzzle documentation: Request options (multipart, timeout)
- Laravel documentation: HTTP Client
- PHP manual: CURLFile
Louis Desclous
Published on · Reading time: 11 minutes

