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
sharpapi-resume-parser
sharpapi/laravel-resume-parser
claude-code
sharpapi
resume-parser
queued-jobs
polling
mocking
file-uploads

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.

Install
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'])`).

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:
    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

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):

{
  "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

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.
    $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)):

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'])).

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.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager