Shared foundation for the plin-code job board connector packages.
It holds the four things every connector needs and nothing else:
JobBoardClient, the contract a connector implements.JobPostingDTO, the shape a connector returns.- A small exception hierarchy.
- A thin PSR-18 helper so connectors do not each hand roll HTTP.
The package is framework agnostic: no Laravel, no service provider, no facades, no logging.
There is nothing to run here. Install it because you are writing (or using) a connector such as
plin-code/job-boards-personio or plin-code/job-boards-smartrecruiters. Those packages depend on
this one; an application normally installs them, not this.
composer require plin-code/job-boards-coreRequires PHP 8.3 or newer.
This package depends on the PSR-18 client interface and the PSR-17 request factory interface, never on an implementation. You inject the client you already have.
In a Laravel application, Guzzle is already installed and GuzzleHttp\Client is a PSR-18 client, while
GuzzleHttp\Psr7\HttpFactory is a PSR-17 factory:
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
use PlinCode\JobBoards\Http\HttpClient;
$http = new HttpClient(
client: new Client(['timeout' => 30]),
requestFactory: new HttpFactory,
logger: Log::channel('stack'), // optional, see "Logging" below
);In a Symfony application, Symfony\Component\HttpClient\Psr18Client is both:
use PlinCode\JobBoards\Http\HttpClient;
use Symfony\Component\HttpClient\Psr18Client;
$psr18 = new Psr18Client;
$http = new HttpClient(client: $psr18, requestFactory: $psr18);Any other PSR-18 client works the same way.
use PlinCode\JobBoards\Contracts\JobBoardClient;
use PlinCode\JobBoards\Data\JobPostingDTO;
use PlinCode\JobBoards\Http\HttpClient;
final class AcmeJobBoardClient implements JobBoardClient
{
public function __construct(private readonly HttpClient $http) {}
/**
* @return list<JobPostingDTO>
*/
public function fetchJobsForCompany(string $slug): array
{
$response = $this->http->tryGet("https://api.acme.test/{$slug}/jobs", ['limit' => 100]);
if ($response === null || ! $response->successful()) {
return [];
}
$jobs = [];
foreach ($response->jsonArray('content') as $job) {
if (! is_array($job)) {
continue;
}
$jobs[] = new JobPostingDTO(
externalId: (string) $job['id'],
title: (string) $job['title'],
location: $job['location'] ?? null,
url: (string) $job['url'],
department: $job['department'] ?? null,
rawPayload: $job,
);
}
return $jobs;
}
public function validateSlug(string $slug): ?string
{
$response = $this->http->withTimeout(15.0)->tryGet("https://api.acme.test/{$slug}");
return $response?->successful() ? (string) $response->json('name') : null;
}
public function fetchCompanyDescription(string $slug): ?string
{
return null;
}
}PlinCode\JobBoards\Http\HttpClient does the handful of things a job board connector actually does.
| Method | Behaviour |
|---|---|
get(string $url, array $query = []): Response |
GET with the query appended. Throws TransportException when no response could be obtained. |
tryGet(string $url, array $query = []): ?Response |
Same, but returns null on a transport failure instead of throwing. |
withTimeout(?float $seconds): self |
Copy with a timeout. See the caveat below. |
withHeaders(array $headers): self |
Copy with extra request headers. |
withLogger(?LoggerInterface $logger): self |
Copy with a PSR-3 logger. |
timeout(): ?float |
The timeout currently requested, if any. |
Query values that are null are dropped, so an optional parameter can be expressed inline:
$this->http->tryGet($url, ['language' => $inEnglish ? 'en' : null]);Response is what came back, whatever the status:
| Method | Behaviour |
|---|---|
status(): int |
The HTTP status code. |
successful(): bool / failed(): bool |
Whether the status is 2xx. |
body(): string |
The raw body, untouched. XML boards read this. |
json(?string $key = null, mixed $default = null): mixed |
The decoded body, or one value addressed with dot notation (company.name). |
jsonArray(?string $key = null): array |
The same, but insists on an array so callers get a typed result. |
A connector treats "the board answered 404" and "the board did not answer" differently, so the helper keeps them apart:
- A response arrived, whatever the status: you get a
Response. A non 2xx is not an exception, because only the connector knows whether a 404 means "no such company" or "something broke". - No response arrived (DNS, refused connection, TLS, timeout):
get()throwsTransportException,tryGet()returnsnull. - A response arrived but the body is unreadable as JSON:
json()throwsInvalidResponseException. A 200 carrying an HTML error page is a bug, not a result.
Both exceptions implement PlinCode\JobBoards\Exceptions\JobBoardException, so a consumer can catch
the whole family at once.
PSR-18 has no timeout abstraction. There is no standard way to tell an arbitrary PSR-18 client "take at most 15 seconds for this request", and this package will not pretend otherwise.
So the timeout normally lives on the client you inject (new GuzzleHttp\Client(['timeout' => 30])),
and withTimeout() is a no-op for such a client. timeout() still reports the value you asked for.
If you need per-request timeouts, which some boards do (a short one for slug validation, a longer one
for a full sync), implement PlinCode\JobBoards\Http\SupportsTimeout on the client you inject. When
the injected client advertises that interface, HttpClient calls it before each request:
use GuzzleHttp\Client;
use PlinCode\JobBoards\Http\SupportsTimeout;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
final class GuzzlePsr18Client implements ClientInterface, SupportsTimeout
{
public function __construct(private readonly float $timeout = 30.0) {}
public function withTimeout(float $seconds): ClientInterface
{
return new self($seconds);
}
public function sendRequest(RequestInterface $request): ResponseInterface
{
return (new Client(['timeout' => $this->timeout, 'http_errors' => false]))->send($request);
}
}This package never logs. Logging is a policy decision, and a library that writes to a channel it chose itself is a nuisance in someone else's application.
Instead:
- A connector reports a soft failure through its return value.
fetchJobsForCompany()returns an empty array,validateSlug()andfetchCompanyDescription()returnnull. A daily sync across many companies must not abort because one board is down. - If you want a record of why, inject a PSR-3
LoggerInterface. It is optional and defaults tonull; the helper logs exactly one thing, a transport failure swallowed bytryGet().
psr/log is an interface-only package with no dependencies, and Laravel's logger already implements
LoggerInterface, so Log::channel('stack') can be passed straight in. The alternative, inventing an
event or callback abstraction, would force every consumer to write an adapter for something PSR-3
already standardises.
composer install
composer test # Pest
composer analyse # PHPStan
composer format # PintMIT. See LICENSE.md.
