Building a Reusable PHP SDK, Composer Package Structure
Turn your one-off scraper into a publishable SDK: composer.json, namespace, autoload, version constraints, tests. The full package skeleton.
What you’ll learn
- Lay out a Composer package with PSR-4 autoload and a vendor namespace.
- Write a sensible `composer.json` (description, keywords, deps, autoload).
- Add a basic PHPUnit test against the live Catalog108 API.
- Tag versions semver-correctly.
A scraper class in a single file is fine for one project. The moment two of your projects need the same Catalog108 client, you should publish it, even just to a private Composer repository. Versioned, autoloaded, tested. Everyone who depends on it benefits.
This lesson is the skeleton.
Directory layout
catalog108-sdk/
├── composer.json
├── .gitignore
├── README.md
├── LICENSE
├── phpunit.xml.dist
├── src/
│ ├── Catalog108Client.php
│ ├── Exception/
│ │ ├── ApiException.php
│ │ ├── AuthException.php
│ │ └── RateLimitException.php
│ └── Model/
│ ├── Product.php
│ └── Review.php
└── tests/
└── Catalog108ClientTest.php
Conventions: source under src/, tests under tests/, support files (license, readme) at the root.
composer.json
{
"name": "yourvendor/catalog108-sdk",
"description": "Unofficial PHP SDK for the Catalog108 practice API at practice.scrapingcentral.com.",
"type": "library",
"license": "MIT",
"keywords": ["scraping", "catalog108", "api", "sdk"],
"authors": [
{
"name": "Your Name",
"email": "you@example.com"
}
],
"require": {
"php": "^8.1",
"guzzlehttp/guzzle": "^7.5",
"psr/http-client": "^1.0"
},
"require-dev": {
"phpunit/phpunit": "^10.0",
"phpstan/phpstan": "^1.10"
},
"autoload": {
"psr-4": {
"YourVendor\\Catalog108\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"YourVendor\\Catalog108\\Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit",
"phpstan": "phpstan analyse src --level=8"
},
"minimum-stability": "stable",
"config": {
"sort-packages": true
}
}
Things to notice:
nameisvendor/package, your Packagist handle. Use a stable vendor name (your GitHub username or org).require.phpspecifies the lowest PHP version you support.^8.1means 8.1.0 through anything < 9.0.autoload.psr-4maps your namespace prefix to thesrc/directory.YourVendor\Catalog108\Clientlives atsrc/Client.php.require-devare tools you only need while developing (PHPUnit, PHPStan).scriptslets you runcomposer testinstead of./vendor/bin/phpunit.
The main class, src/Catalog108Client.php
<?php
declare(strict_types=1);
namespace YourVendor\Catalog108;
use GuzzleHttp\Client as Guzzle;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use YourVendor\Catalog108\Exception\{ApiException, AuthException, RateLimitException};
class Catalog108Client {
public const VERSION = '1.0.0';
public const BASE_URI = 'https://practice.scrapingcentral.com';
private Guzzle $http;
private ?string $token = null;
public function __construct(array $options = []) {
$stack = HandlerStack::create();
// retry middleware as in lesson 3.12
$this->http = new Guzzle(array_merge([
'base_uri' => self::BASE_URI,
'timeout' => 10,
'headers' => [
'Accept' => 'application/json',
'User-Agent' => 'catalog108-sdk-php/' . self::VERSION,
],
'handler' => $stack,
], $options));
}
public function login(string $email, string $password): array {
$data = $this->call('POST', '/api/auth/login', ['json' => compact('email', 'password')]);
$this->token = $data['access_token'];
return $data;
}
public function products(int $page = 1, int $perPage = 12): array {
return $this->call('GET', '/api/products', ['query' => ['page' => $page, 'per_page' => $perPage]]);
}
public function product(int $id): array {
return $this->call('GET', "/api/products/{$id}");
}
private function call(string $method, string $path, array $options = []): array {
if ($this->token) {
$options['headers']['Authorization'] = "Bearer {$this->token}";
}
try {
$res = $this->http->request($method, $path, $options);
} catch (\GuzzleHttp\Exception\ClientException $e) {
$code = $e->getResponse()->getStatusCode();
if ($code === 401) throw new AuthException('Auth failed', 401, $e);
if ($code === 429) throw new RateLimitException('Rate limited', 429, $e);
throw new ApiException("HTTP $code", $code, $e);
}
return json_decode($res->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR);
}
}
Custom exceptions, src/Exception/*
// src/Exception/ApiException.php
<?php
declare(strict_types=1);
namespace YourVendor\Catalog108\Exception;
class ApiException extends \RuntimeException {}
// src/Exception/AuthException.php
<?php
declare(strict_types=1);
namespace YourVendor\Catalog108\Exception;
class AuthException extends ApiException {}
// src/Exception/RateLimitException.php
<?php
declare(strict_types=1);
namespace YourVendor\Catalog108\Exception;
class RateLimitException extends ApiException {}
These let consumers catch (RateLimitException $e) specifically.
A live-integration test
// tests/Catalog108ClientTest.php
<?php
declare(strict_types=1);
namespace YourVendor\Catalog108\Tests;
use PHPUnit\Framework\TestCase;
use YourVendor\Catalog108\Catalog108Client;
class Catalog108ClientTest extends TestCase {
public function testFetchProducts(): void {
$client = new Catalog108Client();
$data = $client->products(1, 5);
$this->assertArrayHasKey('products', $data);
$this->assertCount(5, $data['products']);
}
public function testLoginAndMe(): void {
$client = new Catalog108Client();
$client->login('student@practice.scrapingcentral.com', 'practice123');
$me = $client->call('GET', '/api/auth/me');
$this->assertSame('student@practice.scrapingcentral.com', $me['email']);
}
}
Run with composer test. Live-integration tests hit the real Catalog108, fast feedback that the SDK still works against the actual API. For unit tests of internal logic, mock the Guzzle handler.
phpunit.xml.dist
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="Default">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
.gitignore
/vendor/
/composer.lock
.phpunit.cache
.phpunit.result.cache
For libraries, do NOT commit composer.lock; you want consumers to resolve their own deps within your constraints.
README skeleton
Include: install command (composer require yourvendor/catalog108-sdk), 3-line quickstart, list of methods, supported PHP versions, license. Don't over-document, link to a docs/ if you grow.
Semver and tagging
Once you publish, every breaking change requires a major version bump:
1.0.0, initial public release.1.0.1, bug fix.1.1.0, new method added, no break.2.0.0, renamed/removed a method, or bumped minimum PHP version.
git tag -a v1.0.0 -m "Initial release"
git push origin v1.0.0
Packagist auto-detects the tag.
Hands-on lab
Create a catalog108-sdk directory locally with the layout above. Implement Catalog108Client, two exception classes, and the two integration tests. Run composer test. You now have a publishable PHP SDK, the next lesson covers actually pushing it to Packagist.
Hands-on lab
Practice this lesson on Catalog108, our first-party scraping sandbox.
Open lab target →/api/productsQuiz, check your understanding
Pass mark is 70%. Pick the best answer; you’ll see the explanation right after.