Skip to content

How to Parse a Resume to JSON in PHP with Guzzle or Laravel

Send a CV to a resume parser API from PHP 8.2, map the JSON to readonly classes, handle every documented error code and retry only what is safe.

Published · 11 minutes read

Cover reading “Parse a resume to JSON in PHP” with a document icon and a code icon

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 curl and json extensions. 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_KEY environment 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.

ItemValue
EndpointPOST https://hirelayer.co/api/v3/parser
AuthenticationX-API-Key header. Authorization: Bearer is not accepted.
Bodymultipart/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.
FormatsPDF, DOC, DOCX, ODT, PPT, PPTX, ODP, XLS, RTF, TXT, JPG/JPEG, PNG and BMP. The type is detected from the content.
SizeEncoded upload up to 6 MiB, which means a file of about 4.5 MB. Up to 100,000 extracted characters.
TimingUsually about 35 seconds, longer for scans. The API answers 504 after 145 seconds, so use a client timeout of at least 150 seconds.
Cost1 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-DD strings. 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_date is null for an ongoing role, and currently_active says so explicitly.
  • contract_type, experience_level, education_level and language level use fixed English values, which makes them safe to turn into PHP enums. educations[].degree_type is not normalized: treat unknown values as Other.
  • Free text stays in the resume’s language. Skills with status: normalized use French taxonomy labels; skills with status: raw keep 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, 503 and 504 with exponential backoff, and use the Retry-After header when the response has one. Failed calls are not charged.
  • Never retry a 4xx unchanged. 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, so 429 is 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.

StatusWhat it meansWhat to do
400No 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.
401Missing API Key or Invalid API Key (malformed, unknown or revoked).Fix the key and alert whoever owns it.
403Insufficient credits available.Pause sending, top up, then retry.
413The encoded upload is over 6 MiB.Ask for a file under 4.5 MB.
415The body is not multipart/form-data.Fix the request.
422DOCUMENT_NOT_A_RESUME, DOCUMENT_TEXT_EMPTY, DOCUMENT_UNREADABLE or DOCUMENT_TOO_LARGE.Show the candidate a clear message.
500, 502, 503, 504A 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 5xx responses, and connections that were never made, are retried, with backoff.
  • Every failure is logged with its status, code and x-parser-request-id, never with the resume contents.
  • do_not_store_data matches 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

  1. HireLayer API documentation: Parse a resume (V3)
  2. HireLayer API documentation: Errors and retries
  3. Guzzle documentation: Request options (multipart, timeout)
  4. Laravel documentation: HTTP Client
  5. PHP manual: CURLFile

Louis Desclous

Published on · Reading time: 11 minutes