Template for a plin-code job board connector. Clone it, run the renaming checklist below, and you have a package that speaks the same contract as every other connector in the family.
It is built on plin-code/job-boards-core, which supplies the JobBoardClient contract, the JobPostingDTO and a small PSR-18 wrapper. This package supplies the two files core deliberately does not: a concrete client and a Laravel service provider.
src/
βββ SkeletonClient.php implements JobBoardClient, framework agnostic
βββ SkeletonServiceProvider.php the only Laravel aware file
config/job-boards-skeleton.php
tests/
βββ Unit/ the client, no framework booted
βββ Feature/ the service provider, under Testbench
The PSR-18 test doubles are not in here. FakePsrClient and RecordingLogger live in core under PlinCode\JobBoards\Testing, so every connector fakes the transport the same way.
SkeletonClient is a working placeholder, not pseudo code. It talks to a fictional provider whose account endpoint answers with
{ "name": "Acme", "about": "We build things", "openings": [ { "id": "ABC123", "title": "Backend Engineer" } ] }and its tests pass against a faked PSR-18 client. Point it at a real provider, rename the payload keys, and it works.
SkeletonClient takes core's HttpClient and an optional PSR-3 logger. It imports nothing from Laravel. A Symfony or plain PHP consumer builds it directly:
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\HttpFactory;
use PlinCode\JobBoards\Http\HttpClient;
use PlinCode\JobBoards\Skeleton\SkeletonClient;
$http = new HttpClient(new Client, new HttpFactory);
$client = new SkeletonClient($http);
$jobs = $client->fetchJobsForCompany('acme'); // list<JobPostingDTO>
$name = $client->validateSlug('acme'); // ?string
$about = $client->fetchCompanyDescription('acme'); // ?stringSkeletonServiceProvider exists only to do that same wiring out of the container. Keep it that way: if you find yourself reaching for a facade, a helper or config() inside the client, move it to the provider instead.
composer require plin-code/job-boards-skeletonThe provider is auto discovered. Resolve the client and go:
$client = app(\PlinCode\JobBoards\Skeleton\SkeletonClient::class);Publish the config if you need to change the base URL, the timeouts or the request headers:
php artisan vendor:publish --tag=job-boards-skeleton-configThe provider binds a PSR-18 client and a PSR-17 factory with bindIf, so an application that already binds its own keeps it. It deliberately does not bind JobBoardClient itself: several connectors implement that interface and would fight over the binding. Bind the one you want in your own application service provider.
fetchJobsForCompany() never throws at the caller. A failed status, a missing payload key or a dead connection is logged through the injected PSR-3 logger and returns an empty list, so one broken company cannot abort a sync over hundreds of them. When no logger is passed, a NullLogger is used and everything is silent.
validateSlug() and fetchCompanyDescription() return null for every failure, including transport failures. That is intentional: neither a 404 nor a dropped connection proves a slug is good.
PSR-18 has no notion of a timeout, so core's HttpClient::withTimeout() is only honoured by clients implementing PlinCode\JobBoards\Http\SupportsTimeout. Guzzle's PSR-18 client does not, so the configured timeout is a request the transport may ignore. If timeouts matter to you, build the Guzzle client with ['timeout' => 30] and bind it yourself, or wrap it in a small SupportsTimeout adapter.
Everything below is mechanical. Skeleton becomes your provider name in PascalCase (Greenhouse), skeleton its kebab-case form (greenhouse).
- Directory: rename the clone to
job-boards-greenhouse. - Namespace:
PlinCode\JobBoards\Skeleton\becomesPlinCode\JobBoards\Greenhouse\insrc/*.php, incomposer.jsonunderautoload.psr-4, and inextra.laravel.providers. - Test namespace:
PlinCode\JobBoards\Skeleton\Tests\becomesPlinCode\JobBoards\Greenhouse\Tests\incomposer.jsonunderautoload-dev.psr-4and in every file undertests/. - Class names:
SkeletonClientbecomesGreenhouseClient,SkeletonServiceProviderbecomesGreenhouseServiceProvider. Rename the files to match. - Config file: rename
config/job-boards-skeleton.phptoconfig/job-boards-greenhouse.php. Update->hasConfigFile('job-boards-greenhouse')and->name('job-boards-greenhouse')in the provider, theconfig('job-boards-greenhouse...')key it reads, and theJOB_BOARDS_SKELETON_*env var names inside the file. - Composer name:
plin-code/job-boards-skeletonbecomesplin-code/job-boards-greenhouse. Updatedescription,keywordsandhomepagetoo. - Provider registration:
extra.laravel.providersbecomes["PlinCode\\JobBoards\\Greenhouse\\GreenhouseServiceProvider"]. - Client body: replace
API_BASE_URL, the two timeout constants, the payload keys infetchJobsForCompany()andlookup(), and the field mapping inmapToDTO(). Update the log messages, which name the provider. - Tests: rename
tests/Unit/SkeletonClientTest.phpandtests/Feature/SkeletonServiceProviderTest.php, and update the fixtures to the real payload shape. - README: this file. Replace the fictional payload, keep the Framework agnostic and Error handling sections.
- Delete
art/. Those images are the skeleton's own banner. Regenerate the package's artwork fromplin-code/package-artafter the repository exists, or the README will show the wrong name.
A sed pass covers steps 2, 3, 4, 6 and 7 in one go:
grep -rl 'Skeleton\|skeleton\|SKELETON' --exclude-dir=vendor --exclude-dir=build . \
| xargs sed -i '' -e 's/Skeleton/Greenhouse/g' -e 's/skeleton/greenhouse/g' -e 's/SKELETON/GREENHOUSE/g'
git mv src/SkeletonClient.php src/GreenhouseClient.php
git mv src/SkeletonServiceProvider.php src/GreenhouseServiceProvider.php
git mv config/job-boards-skeleton.php config/job-boards-greenhouse.php
git mv tests/Unit/SkeletonClientTest.php tests/Unit/GreenhouseClientTest.php
git mv tests/Feature/SkeletonServiceProviderTest.php tests/Feature/GreenhouseServiceProviderTest.phpAll three cases matter: SKELETON on its own catches the JOB_BOARDS_SKELETON_* env var names in the config file, which the other two miss.
Steps 8, 9 and 10 still need eyes on them.
"require": {
"plin-code/job-boards-core": "^0.2||^0.3"
}Core is on Packagist, so that constraint is all this package needs: there is no repositories block to carry. Do not commit a path repository pointing at a sibling checkout of core. It resolves against the layout of one machine, and the package then fails to install from a fresh clone anywhere else.
composer install
composer lint # pint, writes
composer lint:check # pint, read only
composer analyse # phpstan level 10, matching core
composer test:unit # pest
composer test # analyse + lint:check + test:unitThe PSR-18 test doubles come from core, under PlinCode\JobBoards\Testing: FakePsrClient (queue responses, assert on lastUri()), PlainPsrClient (does not advertise timeout support), RecordingLogger and FakeNetworkException. They ship in core's src/, not its autoload-dev, precisely so every connector uses the same ones instead of keeping a copy that drifts.
The MIT License (MIT). Please see License File for more information.
