Scraping Central is reader-supported. When you buy through links on our site, we may earn an affiliate commission.

3.14intermediate4 min read

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:

  • name is vendor/package, your Packagist handle. Use a stable vendor name (your GitHub username or org).
  • require.php specifies the lowest PHP version you support. ^8.1 means 8.1.0 through anything < 9.0.
  • autoload.psr-4 maps your namespace prefix to the src/ directory. YourVendor\Catalog108\Client lives at src/Client.php.
  • require-dev are tools you only need while developing (PHPUnit, PHPStan).
  • scripts lets you run composer test instead 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/products

Quiz, check your understanding

Pass mark is 70%. Pick the best answer; you’ll see the explanation right after.

Building a Reusable PHP SDK, Composer Package Structure1 / 8

Which composer.json key maps a namespace prefix to a directory for autoloading?

Score so far: 0 / 0