Parse resume / CV files into structured candidate data with sharpapi/laravel-resume-parser (ResumeParserService::parseResume, fetchResults, SharpApiJob, config/sharpapi-resume-parser.php). Use when extracting candidate name, email, positions, education or skills from an uploaded CV, running the parse in a queued job, handling a failed or timed-out parse, or mocking the parser in tests.
php artisan boost:add-skill sharpapi/laravel-resume-parser
Save this content to: .claude/skills/sharpapi-resume-parser/SKILL.md
# SharpAPI Resume Parser
## When to use this skill
- Turning an uploaded resume/CV file into structured data (candidate contact details, positions, education, languages, certifications).
- Writing the queued job that submits the file and waits for the result.
- Handling a failed parse, a polling timeout or a rate limit.
- Mocking the parser in feature tests.
Supported files (per the README): DOC, DOCX, TXT, RTF, PDF, JPG, JPEG, JPE, PNG, TIFF, TIF, including image-only PDFs. The README states no file size limit.
## Install / config
1. `composer require sharpapi/laravel-resume-parser`. The provider `SharpAPI\ResumeParser\ResumeParserProvider` is auto-discovered. Requires `sharpapi/php-core` ≥ 1.4.1 (pulled in automatically).
2. `.env`:
```dotenv
SHARP_API_KEY=your-api-key
# optional
SHARP_API_BASE_URL=https://sharpapi.com/api/v1
SHARP_API_JOB_STATUS_POLLING_WAIT=180 # max seconds fetchResults() blocks
SHARP_API_JOB_STATUS_POLLING_INTERVAL=10 # seconds between polls...
SHARP_API_JOB_STATUS_USE_POLLING_INTERVAL=false # ...only used when true; otherwise the API's Retry-After wins
```
3. Optional: `php artisan vendor:publish --tag=sharpapi-resume-parser` publishes `config/sharpapi-resume-parser.php` (keys `api_key`, `base_url`, `api_job_status_polling_wait`, `api_job_status_polling_interval`, `api_job_status_use_polling_interval`).
There is no facade and no container binding. `ResumeParserService` has a no-argument constructor that reads the config, so inject it (preferred, mockable) or `new` it. A missing key throws `InvalidArgumentException` when the service is constructed.
## API
```php
use SharpAPI\ResumeParser\ResumeParserService;
public function parseResume(string $filePath, ?string $language = null): string // returns a status URL
```
- `$filePath`: a local, readable file path. The client reads it with `file_get_contents()` and uploads it as multipart, sending `basename($filePath)` as the filename.
- `$language`: output language as a full English name (`'English'`, `'German'`, `'Spanish'`), not an ISO code. `null` leaves it to the API default (English).
- Returns the job's status URL, not the result. Get the result with `fetchResults($statusUrl)`.
`fetchResults(string $statusUrl): SharpAPI\Core\DTO\SharpApiJob` (inherited from `SharpAPI\Core\Client\SharpApiClient`) polls until the job is `success` or `failed`. The DTO has `id`, `type` (`'hr_parse_resume'`), `status` (string) and `result` (`?stdClass`), plus `getResultJson()`, `getResultArray()`, `getResultObject()`, `toArray()`. `quota()` and `ping()` are also inherited.
Result shape (from the README example, trimmed):
```json
{
"candidate_name": "Linda Harris",
"candidate_email": "[email protected]",
"candidate_phone": "02476 000 0000, 0887 222 9999",
"candidate_address": "34 Made Up Road, Coventry, CV66 7RF",
"candidate_language": "English",
"candidate_spoken_languages": ["German"],
"candidate_honors_and_awards": [],
"candidate_courses_and_certifications": ["ISEB certification"],
"positions": [
{
"position_name": "Test Engineer",
"company_name": "IT & Telecoms Company",
"country": "United Kingdom",
"start_date": "2008-06-01",
"end_date": null,
"skills": ["Acceptance testing", "Agile", "Integration testing"],
"job_details": "Responsible for the whole test process..."
}
],
"education_qualifications": [
{
"school_name": "Nuneaton University",
"school_type": "University or equivalent",
"degree_type": "Bachelor’s Degree or equivalent",
"specialization_subjects": "Software Engineering",
"faculty_department": "",
"learning_mode": "In-person learning",
"education_details": "",
"country": "United Kingdom",
"start_date": "2005-09-01",
"end_date": "2008-06-01"
}
]
}
```
Read fields defensively (`$data['candidate_email'] ?? null`): a resume without an email or phone gives empty values.
## Recipe: queued job
```php
namespace App\Jobs;
use App\Models\Candidate;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use SharpAPI\Core\Enums\SharpApiJobStatusEnum;
use SharpAPI\ResumeParser\ResumeParserService;
class ParseCandidateResume implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1; // a retry re-uploads the CV and burns quota
public int $timeout;
public bool $failOnTimeout = true;
public function __construct(public Candidate $candidate)
{
// fetchResults() may block for the whole polling wait; add headroom for the upload.
$this->timeout = (int) config('sharpapi-resume-parser.api_job_status_polling_wait', 180) + 60;
}
public function handle(ResumeParserService $parser): void
{
$path = Storage::disk('local')->path($this->candidate->cv_path); // local and readable by the worker
$statusUrl = $parser->parseResume($path, 'English');
$job = $parser->fetchResults($statusUrl); // blocks until success or failed
if ($job->status !== SharpApiJobStatusEnum::SUCCESS->value) {
throw new RuntimeException("SharpAPI resume parsing failed (job {$job->id}).");
}
$data = json_decode($job->getResultJson(), true);
$this->candidate->update([
'name' => $data['candidate_name'] ?? null,
'email' => $data['candidate_email'] ?? null,
'parsed_cv' => $data,
]);
}
}
```
- Files on S3 or another remote disk: copy to a local temp file first, keep the original extension in its name (the basename is the upload filename), and delete it in a `finally` block.
```php
$tmp = sys_get_temp_dir().'/'.Str::uuid().'_'.basename($key);
file_put_contents($tmp, Storage::disk('s3')->get($key));
try { $statusUrl = $parser->parseResume($tmp, 'English'); } finally { @unlink($tmp); }
```
- Need retries? Save `$statusUrl` on the model right after `parseResume()`. On a retry, skip the upload and call only `fetchResults()` on the saved URL; it only reads the job status.
- One job that also calls another SharpAPI service (e.g. `sharpapi/laravel-resume-job-match-score`) must set `$timeout` to the sum of both polling waits plus headroom.
- The queue connection's `retry_after` must be greater than the job `$timeout`, and a Horizon supervisor's `timeout` at least as large, or the job runs twice.
## Gotchas
1. **`fetchResults()` already blocks and polls** (up to `api_job_status_polling_wait`, honouring the API's `Retry-After`). Call it once. Never wrap it in `while ($job->status === 'pending')`: it only returns on `success` or `failed`, and each extra call starts a new wait.
2. **A failed job does not throw.** It returns a `SharpApiJob` with `status === 'failed'` and no usable `result`. Compare `$job->status` with `SharpAPI\Core\Enums\SharpApiJobStatusEnum::SUCCESS->value`.
3. **Never call `fetchResults()` in an HTTP request.** Use a queued job whose `$timeout` exceeds the polling wait, with low `$tries`.
4. **Use `json_decode($job->getResultJson(), true)` for arrays.** `getResultArray()` casts only the top level. Whether `positions` and `education_qualifications` items come back as arrays or `stdClass` depends on the status URL form php-core received, so the `json_decode` form is the only one that is always plain arrays.
5. **Language is a full name** (`'English'`), not `'en'`.
6. **Local readable path only.** A Storage key, an S3 path or an `UploadedFile` does not work, and the request's temp upload is gone by the time a queued job runs. A missing file surfaces as an `ErrorException` from `file_get_contents()`.
7. Errors: a polling timeout throws `SharpAPI\Core\Exceptions\ApiException` ("Polling timed out ..."); a 429 on submit is tried 3 times in total, then throws `ApiException` with code 429. Other 4xx/5xx surface as Guzzle exceptions (`GuzzleHttp\Exception\ClientException` / `GuzzleException`).
## Testing
php-core sends requests with its own Guzzle client, so **`Http::fake()` does not intercept them**. Mock the service instead (works when the code resolves it from the container, e.g. `handle(ResumeParserService $parser)`):
```php
use Mockery\MockInterface;
use SharpAPI\Core\DTO\SharpApiJob;
use SharpAPI\ResumeParser\ResumeParserService;
it('stores the parsed resume', function () {
$this->mock(ResumeParserService::class, function (MockInterface $mock) {
$mock->shouldReceive('parseResume')->once()->andReturn('https://sharpapi.com/api/v1/job/status/job-1');
$mock->shouldReceive('fetchResults')->once()->andReturn(new SharpApiJob(
id: 'job-1',
type: 'hr_parse_resume',
status: 'success',
result: (object) ['candidate_name' => 'Linda Harris', 'candidate_email' => '[email protected]', 'positions' => []],
));
});
// dispatch the job synchronously and assert on the model
});
```
Cover the failed path with `status: 'failed', result: new stdClass`. `$this->mock()` does not run the constructor, so tests need no API key; code that resolves the real service does (`config(['sharpapi-resume-parser.api_key' => 'test-key'])`).
Supported files (per the README): DOC, DOCX, TXT, RTF, PDF, JPG, JPEG, JPE, PNG, TIFF, TIF, including image-only PDFs. The README states no file size limit.
composer require sharpapi/laravel-resume-parser. The provider SharpAPI\ResumeParser\ResumeParserProvider is auto-discovered. Requires sharpapi/php-core ≥ 1.4.1 (pulled in automatically)..env:
SHARP_API_KEY=your-api-key
# optional
SHARP_API_BASE_URL=https://sharpapi.com/api/v1
SHARP_API_JOB_STATUS_POLLING_WAIT=180 # max seconds fetchResults() blocks
SHARP_API_JOB_STATUS_POLLING_INTERVAL=10 # seconds between polls...
SHARP_API_JOB_STATUS_USE_POLLING_INTERVAL=false # ...only used when true; otherwise the API's Retry-After wins
php artisan vendor:publish --tag=sharpapi-resume-parser publishes config/sharpapi-resume-parser.php (keys api_key, base_url, api_job_status_polling_wait, api_job_status_polling_interval, api_job_status_use_polling_interval).There is no facade and no container binding. ResumeParserService has a no-argument constructor that reads the config, so inject it (preferred, mockable) or new it. A missing key throws InvalidArgumentException when the service is constructed.
use SharpAPI\ResumeParser\ResumeParserService;
public function parseResume(string $filePath, ?string $language = null): string // returns a status URL
$filePath: a local, readable file path. The client reads it with file_get_contents() and uploads it as multipart, sending basename($filePath) as the filename.$language: output language as a full English name ('English', 'German', 'Spanish'), not an ISO code. null leaves it to the API default (English).fetchResults($statusUrl).fetchResults(string $statusUrl): SharpAPI\Core\DTO\SharpApiJob (inherited from SharpAPI\Core\Client\SharpApiClient) polls until the job is success or failed. The DTO has id, type ('hr_parse_resume'), status (string) and result (?stdClass), plus getResultJson(), getResultArray(), getResultObject(), toArray(). quota() and ping() are also inherited.
Result shape (from the README example, trimmed):
{
"candidate_name": "Linda Harris",
"candidate_email": "[email protected]",
"candidate_phone": "02476 000 0000, 0887 222 9999",
"candidate_address": "34 Made Up Road, Coventry, CV66 7RF",
"candidate_language": "English",
"candidate_spoken_languages": ["German"],
"candidate_honors_and_awards": [],
"candidate_courses_and_certifications": ["ISEB certification"],
"positions": [
{
"position_name": "Test Engineer",
"company_name": "IT & Telecoms Company",
"country": "United Kingdom",
"start_date": "2008-06-01",
"end_date": null,
"skills": ["Acceptance testing", "Agile", "Integration testing"],
"job_details": "Responsible for the whole test process..."
}
],
"education_qualifications": [
{
"school_name": "Nuneaton University",
"school_type": "University or equivalent",
"degree_type": "Bachelor’s Degree or equivalent",
"specialization_subjects": "Software Engineering",
"faculty_department": "",
"learning_mode": "In-person learning",
"education_details": "",
"country": "United Kingdom",
"start_date": "2005-09-01",
"end_date": "2008-06-01"
}
]
}
Read fields defensively ($data['candidate_email'] ?? null): a resume without an email or phone gives empty values.
namespace App\Jobs;
use App\Models\Candidate;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use SharpAPI\Core\Enums\SharpApiJobStatusEnum;
use SharpAPI\ResumeParser\ResumeParserService;
class ParseCandidateResume implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1; // a retry re-uploads the CV and burns quota
public int $timeout;
public bool $failOnTimeout = true;
public function __construct(public Candidate $candidate)
{
// fetchResults() may block for the whole polling wait; add headroom for the upload.
$this->timeout = (int) config('sharpapi-resume-parser.api_job_status_polling_wait', 180) + 60;
}
public function handle(ResumeParserService $parser): void
{
$path = Storage::disk('local')->path($this->candidate->cv_path); // local and readable by the worker
$statusUrl = $parser->parseResume($path, 'English');
$job = $parser->fetchResults($statusUrl); // blocks until success or failed
if ($job->status !== SharpApiJobStatusEnum::SUCCESS->value) {
throw new RuntimeException("SharpAPI resume parsing failed (job {$job->id}).");
}
$data = json_decode($job->getResultJson(), true);
$this->candidate->update([
'name' => $data['candidate_name'] ?? null,
'email' => $data['candidate_email'] ?? null,
'parsed_cv' => $data,
]);
}
}
finally block.
$tmp = sys_get_temp_dir().'/'.Str::uuid().'_'.basename($key);
file_put_contents($tmp, Storage::disk('s3')->get($key));
try { $statusUrl = $parser->parseResume($tmp, 'English'); } finally { @unlink($tmp); }
$statusUrl on the model right after parseResume(). On a retry, skip the upload and call only fetchResults() on the saved URL; it only reads the job status.sharpapi/laravel-resume-job-match-score) must set $timeout to the sum of both polling waits plus headroom.retry_after must be greater than the job $timeout, and a Horizon supervisor's timeout at least as large, or the job runs twice.fetchResults() already blocks and polls (up to api_job_status_polling_wait, honouring the API's Retry-After). Call it once. Never wrap it in while ($job->status === 'pending'): it only returns on success or failed, and each extra call starts a new wait.SharpApiJob with status === 'failed' and no usable result. Compare $job->status with SharpAPI\Core\Enums\SharpApiJobStatusEnum::SUCCESS->value.fetchResults() in an HTTP request. Use a queued job whose $timeout exceeds the polling wait, with low $tries.json_decode($job->getResultJson(), true) for arrays. getResultArray() casts only the top level. Whether positions and education_qualifications items come back as arrays or stdClass depends on the status URL form php-core received, so the json_decode form is the only one that is always plain arrays.'English'), not 'en'.UploadedFile does not work, and the request's temp upload is gone by the time a queued job runs. A missing file surfaces as an ErrorException from file_get_contents().SharpAPI\Core\Exceptions\ApiException ("Polling timed out ..."); a 429 on submit is tried 3 times in total, then throws ApiException with code 429. Other 4xx/5xx surface as Guzzle exceptions (GuzzleHttp\Exception\ClientException / GuzzleException).php-core sends requests with its own Guzzle client, so Http::fake() does not intercept them. Mock the service instead (works when the code resolves it from the container, e.g. handle(ResumeParserService $parser)):
use Mockery\MockInterface;
use SharpAPI\Core\DTO\SharpApiJob;
use SharpAPI\ResumeParser\ResumeParserService;
it('stores the parsed resume', function () {
$this->mock(ResumeParserService::class, function (MockInterface $mock) {
$mock->shouldReceive('parseResume')->once()->andReturn('https://sharpapi.com/api/v1/job/status/job-1');
$mock->shouldReceive('fetchResults')->once()->andReturn(new SharpApiJob(
id: 'job-1',
type: 'hr_parse_resume',
status: 'success',
result: (object) ['candidate_name' => 'Linda Harris', 'candidate_email' => '[email protected]', 'positions' => []],
));
});
// dispatch the job synchronously and assert on the model
});
Cover the failed path with status: 'failed', result: new stdClass. $this->mock() does not run the constructor, so tests need no API key; code that resolves the real service does (config(['sharpapi-resume-parser.api_key' => 'test-key'])).
How can I help you explore Laravel packages today?