Background image

API Advice

How to Build an SDK in PHP

Steve McDougall

Steve McDougall

May 1, 2024

Featured blog post image

The ability to streamline and simplify the integration process between systems is getting more and more invaluable. Everything, and I mean everything, is starting to come with its own API. If we want to implement new features, the chances are we will need to work with and external API of some description. Sometimes they offer SDKs, sometimes they don’t. The chances of them supporting your language, or framework, in the way that you need it …. Need I say more?

So learning how to build an SDK in PHP is a skillset you should definitely consider picking up. If you are building your own API and want people to use your service, you will want to provide an SDK for them to use.

In this tutorial, we are going to walk through the decisions you will take when designing an SDK in PHP:

There are many ways to skin a cat, I mean build an SDK. One of the first questions you need to answer is how much opinionation you want to bake into your SDK at the expense of flexibility. My approach will be unopinionated about dependencies, but more opinionated when it comes to the architecture and implementation. That’s because dependencies can be a sticking point for a lot developers, who may feel strongly about Guzzle vs. Symfony or have strict procedures in place for external dependencies. We want to ensure maximum compatibility with the PHP ecosystem. So we need to learn how to build SDKs that work no matter what. Now, let’s walk through how we might go about building an SDK.

What are we building?

We are going to be building an SDK for a fictional e-commerce start up. The primary focus for their API is to allow their customers to sell products. Quite a common use case I am sure we can all agree.

When it comes to building an SDK, the first thing you want to think about is access. What do you want to enable access to, what resources are going to be available, and how should this work. Do we want full access? Do we want partial access, maybe read only access? This is typically tied directly to the abilities of your API.

For the SDK we are going to build, we want to be able to do the following:

  • List all products, allowing the filtering and sorting of results.
  • List a customers order history, and understanding the status of each order.
  • Allowing customers to start creating an order, and progressing it to payment.
  • Generate invoices for orders.

We won’t be handling any payment intents or actual payments in our fictional API, as there are enough payment providers out there already.

At this point we want to start thinking about the configurable options that we might have in our SDK. We can pull out the resources from the list above with relative ease. We will then need an authentication token to be passed in so that our SDK can be authorized to perform actions for us. We will also want some level of store identifier, which will indicate a specific customer’s account. How the API is set up, will depend on how the store identification works. We could use a subdomain identifier for our store, a query parameter, or a header value. It depends on how the API has been implemented. For this let’s assume we are using the subdomain approach, as it is the most common method I have seen.

Thinking about Developer Experience

The DX is something that is important, frustrations with an SDK is the quickest way to lose the adoption you are trying to grow. Bad developer experience signals to developers that your focus isn’t on making their lives easier.

Some common things you should focus on that I find works well for developer experience are:

  • Ensuring compatibility with as many implementations as possible
  • Limiting third-party dependencies that could change behaviour, or break with updates
  • Handling serialization effectively, nobody wants a JSON string to work with - they want objects
  • Supporting pagination for paging through long result sets
  • Providing programatic control over filtering query parameters,

This can tell us a lot about how to start our SDK, as we now know the parameters we need to pass to the constructor. The main thing we want to think about when it comes to our SDK, other than the core functionality, is developer experience.

So let’s start with some code, and I can walk you through the next steps:


declare(strict_types=1);
namespace Acme;
final readonly class SDK
{
public function __construct(
private string $url,
private string $token,
) {}
}

At this point we have an SDK class that we can use to start integrating with. Typically what I like to do is test the integration as I am building, to make sure that I am not going to be creating any pain points that I can solve early on.


$sdk = new Acme\SDK(
url: 'https://acme.some-commerce.com',
token: 'super-secret-api-token',
);

This would typically be loaded in through environment variables and dependency injection, so we wouldn’t construct the SDK directly very often. However, we cannot rely on the assumptions here. In Laravel this would be declared in the following way:


final class IntegrationServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(
abstract: Acme\SDK::class,
concrete: fn () => new Acme\SDK(
url: config('services.commerce.url'),
token: config('services.commerce.token'),
),
);
}
}

We should always test this too, I know I know, why test a constructor? Honestly, it is more of a habit right now than anything. Getting into the practice of testing your code is never a bad thing!


it('can create a new sdk', function (): void {
expect(
new Acme\SDK(
url: 'https://acme.some-commerce.com',
token: 'super-secret-api-token',
),
)->toBeInstanceOf(Acme\SDK::class);
});

As you can see here, I am using Pest PHP for testing. It’s less verbose and I think it’s actually fun to write! I find if you enjoy how you write tests, you are more likely to actually write the tests themselves.

A Note on Authentication

In the example above you’ll notice that I am assuming that you will provide API Tokens for your users to use for their integrations. However, when it comes to APIs there are multiple options available. What is best depends on your usage patterns. You could use OAuth, HTTP Basic, API Token, or Personal Access Tokens. Each option has its benefits, depending on what you need to achieve and what you are providing.

A great example use case of something like OAuth would be if your API or service is designed to be tightly controlled. The implementation is something that you do not want to share credentials with directly, instead you want to proxy the control of this to the service you are authenticating with, which then provides an Access Token that the SDK/implementation can use on the users behalf.

Using HTTP Basic auth is something you see less and less of these days. It used to be extremely popular with government services, where you use you credentials directly to have access remotely. The core principle here is that the application doesn’t care if it is a first or third party, they should all have the same level of control and access.

That leaves API Tokens or Personal Access Tokens. This is my preferred method of authentication. You, as a user, create an API token that you want to use to gain access to the API. You can scope this to specific abilities and permissions, which then allows you to do exactly what you need nothing more. Each token is typically tied directly to a user, or entity. Using this token then also ties any actions you are trying to take directly to the entity the token belongs to. You can quickly and easily revoke these tokens, and you can cascade the deletion of the entity out to the tokens themselves. This is very similar to OAuth, but without as many hoops which makes it a great choice - at least until you actually need OAuth of course.

Designing our Resources

From our testing above, we know the instantiation works. What’s next? Up to this point we have thought about the developer experience, and figured out how we want users to authenticate the SDK. Next we want to start defining the interface for our resources, starting with the Product resources. How I imagine this working is the following:


$sdk->products()->list(); // this should return a collection of products
$sdk->products()->list('parameters'); // We should be able to filter based on parameters

To start building the Product resource out properly, we want to understand the potential options that we will be able to filter based on but also sorting. Personally I like enums for some of this, as it makes the most sense. Using an Enum allows you to tightly control what would be floating constants in your source code, it also gives you control over potential typos from the end user.


enum Sort: string
{
case Price = 'price';
case Age = 'created_at';
// other options you may want to sort on...
}

This would be used like the following:


$sdk->products()->list(
sort: Sort::price,
direction: 'desc|asc',
);

This allows us to easily sort programmatically, giving as much control to the person implementing your SDK as possible.

Working with Query Parameters

So, filtering. Filtering is an interesting one. There are a few different approaches that we could take here, with no clear winner. The option I personally like is passing in a list of filters to iterate over:


$sdk->products()->list(
filters: [
Filter::make(
key: 'brand',
value: 'github',
),
],
);

This allows us to programmatically build up our request exactly as we want it. In theory this is perfect, how about in practice though? Is this going to cause frustrations?


final readonly class IndexController
{
public function __construct(
private SDK $sdk,
private Factory $factory,
) {}
public function __invoke(Request $request): View
{
$products = $this->sdk->products();
$sort = [];
if ($request->has('sort')) {
$sort['on'] = Sort::from(
value: $request->string('sort')->toString(),
);
$sort['direction'] = $request->has('direction')
? $request->string('direction')->toString()
: 'desc';
}
$filters = [];
if ($request->has('filters')) {
foreach ($request->get('filters') as $filter) {
$filters[] = Filter::make(
key: $filter['key'],
value: $filter['value'],
);
}
}
try {
$response = $products->list(
filters: $filters,
sort: $sort['sort'] ?? null,
direction: $sort['direction'] ?? null,
);
} catch (Throwable $exception) {
throw new ProductListException(
message: 'Something went wrong fetching your products.',
previous: $exception,
);
}
return $this->factory->make(
view: 'pages.products.index',
data: [
'products' => $response,
],
);
}
}

So, this isn’t perfect. We start by getting the products resource from the SDK, then process our request to programmatically change how we want to send the request to the API. Now, this is ok, but it is very long winded, which opens it up for issues and user error. We want to control things a little tighter, while still providing that flexibility. It does give me the approach I want and need to get exactly the data I want, but in a way that I personally wouldn’t want to have to use. In reality this would have been wrapped in a Service class to minimize breaking changes.

If we go with this approach, we can now start implementing the resource itself.


final readonly class ProductResource
{
public function __construct(
private SDK $sdk,
) {}
public function list(array $filters = [], null|Sort $sort = null, null|string $direction = null): Collection
{
// build initial request
// build up request with filters and sorting
// send request
// capture response
// throw error if response failed
// return transformed response as a collection
}
}

For now I am just commenting the steps here, because we’ll be stepping through each of these parts one by one.

Building our HTTP Request

Building up the initial request. We want to make sure that we aren’t making any decisions for the user when it comes to their HTTP client. Luckily there is a PSR for that (PSR-17 (opens in a new tab)), which also allows us to leverage auto-discovery.

All of our resources are going to be required to create requests to send. We could either create an abstract resource, or a trait. I personally prefer composition over inheritance, so I would typically lean towards a trait here. The main benefit of composition is that we know that each resource is going to implement similar functionality - however, if we need tighter control over just one thing we can partially pull in a trait or simply not use it. Also, when it comes to testing, testing traits is a lot easier than testing abstract classes.


trait CanCreateRequests
{
public function request(Method $method, string $uri): RequestInterface
{
return Psr17FactoryDiscovery::findRequestFactory()->createRequest(
method: $method->value,
uri: "{$this->sdk->url()}/{$uri}",
);
}
}

This trait allows us to access the discovered Request Factory that implements PSR-17, to then create a request using a passed in method and url. The method here is a simple Enum that allows programatic choice of method, instead of using static variables like GET or POST.

As you can see we need to extend our base SDK class right now, to provide accessors to the private properties of url and later on token.


final readonly class SDK
{
public function __construct(
private string $url,
private string $token,
) {}
public function url(): string
{
return $this->url;
}
public function token(): string
{
return $this->token;
}
}

The first step is done, we can now create the request we need to so that we can build the request as required. The next step is to add the trait to the resource class, so we can implement the required logic.


final readonly class ProductResource
{
use CanCreateRequests;
public function __construct(
private SDK $sdk,
) {}
public function list(array $filters = [], null|Sort $sort = null, null|string $direction = null): Collection
{
$request = $this->request(
method: Method::GET,
uri: '/products',
);
// build up request with filters and sorting
// send request
// capture response
// throw error if response failed
// return transformed response as a collection
}
}

As you can see from the above, to build the request all we need to do is interact with the trait we have created. This will use the PSR-17 factory discovery to find the installed Request Factory, and create the request based on the parameters we sent through. The chances are that we will want to build up our requests in a lot of our resources, so we will need to extend our trait.


trait CanCreateRequests
{
public function request(Method $method, string $uri): RequestInterface
{
return Psr17FactoryDiscovery::findRequestFactory()->createRequest(
method: $method->value,
uri: "{$this->sdk->url()}/{$uri}",
);
}
public function applyFilters(RequestInterface $request, array $filters): RequestInterface
{
foreach ($filters as $filter) {
// now we need to work with the filter itself
}
}
}

But, before we work with the filters on the request we need to understand the options for the filters. They are using query parameters to build the query parameters, which is supported in PSR-7. Let’s look at the filter class, and add a method for working with the filters.


final readonly class Filter
{
public function __construct(
private string $key,
private mixed $value,
) {}
public function toQueryParameter(): array
{
return [
$this->key => $this->value,
];
}
public static function make(string $key, mixed $value): Filter
{
return new Filter(
key: $key,
value: $value,
);
}
}

We just need a way to take the content passed into the filter class, and turn it into an array that we can work with.


trait CanCreateRequests
{
public function request(Method $method, string $uri): RequestInterface
{
return Psr17FactoryDiscovery::findRequestFactory()->createRequest(
method: $method->value,
uri: "{$this->sdk->url()}/{$uri}",
);
}
public function applyFilters(RequestInterface $request, array $filters): RequestInterface
{
$parameters = $request->getQueryParameters();
foreach ($filters as $filter) {
$parameters = array_merge($parameters, $filter->toQueryParameter());
}
return $request->withQueryParameters($parameters);
}
}

In the snippet above, we are extracting the query parameters that may already be in place on our request, then merging them with the passed through filter query parameters, before returning back our modified request. A thing to note here is that the PSR-7 request is typically immutable by default, so you need to make sure that your logic is applied in the way that you expect.


final readonly class ProductResource
{
use CanCreateRequests;
public function __construct(
private SDK $sdk,
) {}
public function list(array $filters = [], null|Sort $sort = null, null|string $direction = null): Collection
{
$request = $this->request(
method: Method::GET,
uri: '/products',
);
$request = $this->applyFilters(
request: $request,
filters: $filters,
);
// send request
// capture response
// throw error if response failed
// return transformed response as a collection
}
}

We can now work on sending the request, and how we might want to achieve that using PSRs too.

PSR-18 and sending HTTP Requests

We’ve already seen PSR-17, but how about PSR-18 (opens in a new tab). It allows a level of interoperability between HTTP clients, so that you aren’t stuck using Guzzle v7.0 or Symfony HTTP Client. Instead, like all good software, you build your implementation to an interface and rely on dependency injection or similar to tell the application exactly what should be used when resolving the interface. This is clean, very testable, and great for building PHP code that isn’t going to break from a random composer update.

How can we implement it though? It can be pretty confusing to try and implement PSRs on their own, the documentation is aimed at library authors who are typically used to reading specification documents. Let’s look at a quote from the specification so you can understand what I mean.

Note that as a result, since PSR-7 objects are immutable (opens in a new tab), the Calling Library MUST NOT assume that the object passed to ClientInterface::sendRequest() will be the same PHP object that is actually sent. For example, the Request object that is returned by an exception MAY be a different object than the one passed to sendRequest(), so comparison by reference (===) is not possible.

Now if you read it, it makes sense! But if you are trying to build against it, along with other PSRs - things can get complicated quickly as the rules pile up in-front of you. This is why we use a library such as PHP HTTP (opens in a new tab), which allows us to auto-discover everything that we need.

Using this library, we are able to discover the HTTP client installed and use it directly. However, I prefer (and recommend) a different approach. The PHP-HTTP library (opens in a new tab) offers a Plugin Client that we can use. This offers more of a composable approach to building up our HTTP client. Let’s look at how we can use this in isolation before how we might implement this into our SDK.


use Http\Discovery\HttpClientDiscovery;
use Http\Client\Common\PluginClient;
$client = new PluginClient(
client: HttpClientDiscovery::find(),
plugins: [],
);

So, our plugin client will use the discovered client, but also accept an array of plugins the can be applied on each request. You can see all of the requirements for this here (opens in a new tab) but I will walk you through a standard approach that I like to use:


use Http\Discovery\HttpClientDiscovery;
use Http\Client\Common\PluginClient;
use Http\Client\Common\Plugin\AuthenticationPlugin;
use Http\Client\Common\Plugin\ErrorPlugin;
use Http\Client\Common\Plugin\RetryPlugin;
use Http\Message\Authentication\Bearer;
$client = new PluginClient(
client: HttpClientDiscovery::find(),
plugins: [
new RetryPlugin(),
new ErrorPlugin(),
new AuthenticationPlugin(
authentication: new Bearer(
token: 'YOUR_API_TOKEN',
),
),
],
);

You can add as many plugins as you need here, there are cache plugins, history plugins, decoding plugins. The list goes on, and is well documented in case you want to build your own plugins. But retry, errors, and authentication is a good list to start with.

Now we know how to build it, we can look at how we might want to implement this. All of our resources are going to want to send requests. But, we don’t want to overload them with traits. To me, the perfect place for this is on the client itself.


final class SDK
{
public function __construct(
private readonly string $url,
private readonly string $token,
private array $plugins = [],
) {}
public function withPlugins(array $plugins): SDK
{
$this->plugins = $plugins;
return $this;
}
public function url(): string
{
return $this->url;
}
public function token(): string
{
return $this->token;
}
}

First we need to start by removing the readonly from the class, and add it to the url and token properties. The reason for this is because our plugins property that we need to add, we want to be able to override, or at least have the option to should we need to. As this will be your SDK, we can customize this a little past this point.


final class SDK
{
public function __construct(
private readonly string $url,
private readonly string $token,
private array $plugins = [],
) {}
public function withPlugins(array $plugins): SDK
{
$this->plugins = array_merge(
$this->defaultPlugins(),
$plugins,
);
return $this;
}
public function defaultPlugins(): array
{
return [
new RetryPlugin(),
new ErrorPlugin(),
new AuthenticationPlugin(
new Bearer(
token: $this->token(),
),
),
];
}
public function url(): string
{
return $this->url;
}
public function token(): string
{
return $this->token;
}
}

Our final step is to have a way to get and override the client that will be used to send all of the HTTP requests. At this point our client is getting pretty big, and people using our SDK may want to implement their own approach. It is important that we avoid making too many decisions for our users. The best way to achieve this, as always, is to code to an interface. Let’s design that now:


interface SDKContract
{
public function withPlugins(array $plugins): SDKContract;
public function defaultPlugins(): array;
public function client(): ClientInterface;
public function setClient(ClientInterface $client): SDKContract;
public function url(): string;
public function token(): string;
}

Let’s focus in on our two new methods:


final class SDK implements SDKContract
{
public function __construct(
private readonly string $url,
private readonly string $token,
private ClientInterface $client,
private array $plugins = [],
) {}
public function client(): ClientInterface
{
return new PluginClient(
client: HttpClientDiscovery::find(),
plugins: $this->defaultPlugins(),
);
}
public function setClient(ClientInterface $client): SDKContract
{
$this->client = $client;
return $this;
}
}

Our client method will return a new Plugin Client, using HTTP discovery to find the client we have installed, also attaching the plugins that we want by default. But, how about if our users want to add additional plugins? How would this look in Laravel?


final class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(
abstract: SDKContract::class,
concrete: fn () => new SDK(
url: config('services.commerce.url'),
token: config('services.commerce.token'),
client: new PluginClient(
client: HttpClientDiscovery::find(),
),
plugins: [
new CustomPlugin(),
],
),
);
}
}

Working with Responses

At this point we are able to send the requests we need - at least for GET requests so far. Next, we want to look at how we receive and process the response data itself. There are two different approaches you could take when it comes to handling responses coming back from your API.

  • Return the PSR-7 Response directly
  • Transform the response into a Data Transfer Object.

There are benefits to each approach, however it mostly depends on the purpose of the SDK. If you want a completely hands free approach, then working with Data Transfer Objects is my recommended approach. Providing strongly typed, contextual objects that your customers can use to work with the data as required. The other option is of course to allow the clients to transform the response, as they see fit.

At this point we need to think back to what this SDK is for. This is an SDK that allows people to integrate with their online stores, so you want to be able to give as much freedom on implementation as possible. However for the purpose of education, let’s show how we might transform this response data anyway.

The way we do this in PHP is by designing our class, and hydrating this as we get the response. A fantastic resource for this is a package called Serde (opens in a new tab) by a member of PHP-FIG, Crell (opens in a new tab). Another option is to use Object Mapper (opens in a new tab) which is by The PHP League. Both libraries offer a very similar functionality, the choice is more down to your personal preference. Our first step is to design the class we want to hydrate, this will typically match your API response.


final readonly class Product
{
public function __construct(
public string $sku,
public string $name,
public string $description,
public int $price,
) {}
}

This assumes that any routing is using the sku to lookup the product. The way I like to use these objects is to add a static method that will hydrate the class. The reason for this is because it keeps all logic about creating the object is contained within the class it is creating. There is no looking around for what class does what, it is all in one place.


final readonly class Product
{
public function __construct(
public string $sku,
public string $name,
public string $description,
public int $price,
) {}
public static function make(array $data): Product
{
$mapper = new ObjectMapperUsingReflection();
return $mapper->->hydrateObject(
className: self::class,
payload: $data,
);
}
}

As you can see, this is a neat bundle that will allow you to just send data to the class and receive the object back in a uniform way. How does this look in our SDK?


final readonly class ProductResource
{
use CanCreateRequests;
public function __construct(
private SDK $sdk,
) {}
public function list(array $filters = [], null|Sort $sort = null, null|string $direction = null): Collection
{
$request = $this->request(
method: Method::GET,
uri: '/products',
);
$request = $this->applyFilters(
request: $request,
filters: $filters,
);
try {
$response = $this->sdk->client()->sendRequest(
request: $request,
);
} catch (Throwable $exception) {
throw new FailedToFetchProducts(
message: 'Failed to fetch product list from API.',
previous: $exception,
);
}
return new Collection(
collectionType: Product::class,
data: array_map(
callback: static fn (array $data): Product => Product::make(
data: $data,
),
array: (array) json_decode(
json: $response->getBody()->getContents(),
associative: true,
flags: JSON_THROW_ON_ERROR,
),
),
);
}
}

This attempts to send the request, and catches any potential exceptions. We then throw a contextual exception so that if anything does go wrong, we capture it and understand exactly what and where things broke. We then return a collection of Products, by mapping over the json response as an array. We are using the Collection (opens in a new tab) library that is built by Ben Ramsey (opens in a new tab) here, not the Laravel one. We could just return an array, but I find it useful if you are going to go to the effort of returning objects, wrapping them in something with additional developer experience is a huge plus.

Pagination

At some point you will need to make a decision about how you want to handle pagination - if at all. Let’s walk through the options, and figure out the what why and hows for paginating your API requests in your SDK.

The first option that most developers reach for is the do while approach. Which you add a do while loop within your code to just crawl the API endpoint until you get to the end of the paginated data - and then return the response. Personally I do not like this approach as it makes a few too many decisions for you. What if you don’t want to fetch all of the data, and just want to first page?

Next up, the paginator class. This will do almost the same as the do while approach, but instead you wrap the SDK call inside a pagination class which will handle the looping for you. This is a little better, as you aren’t mixing the HTTP calls with client intended logic. However to achieve this, you need to add a way to work with pages within your methods.

Finally, the programmatic approach. Much like the paginator class, your method will just accept a nullable page parameter which will request the specific page you actually want. Personally, I like this approach the most. If the client wants to paginate over the data, they have the ability to - without me forcing them into my way of doing it. Let’s have a look at a quick example.


final readonly class ProductResource
{
use CanCreateRequests;
public function __construct(
private SDK $sdk,
) {}
public function list(
array $filters = [],
null|Sort $sort = null,
null|string $direction = null,
null|int $page = null,
): Collection {
$request = $this->request(
method: Method::GET,
uri: '/products',
);
$request = $this->applyFilters(
request: $request,
filters: $filters,
);
if (null !== $page) {
$request = $request->withQueryParameters([
'page' => $page
]);
}
// send request
// capture response
// throw error if response failed
// return transformed response as a collection
}
}

If we pass through a page, we want to make sure we include it in the query parameters being sent over to the API. Your pagination may be different, for example you may use cursor pagination which will require you to pass over a specific hash. Yes the method parameters are getting long, but they all serve a purpose for control. Whoever said methods shouldn’t have more than 3 arguments has never built an SDK before.

On the client side, this is now simple to work with:


$products = $sdk->products()->list(
page: 1,
);

You could even wrap this in your own pagination class or provide a dedicated one with your SDK should you need it. I will show a quick high level interface so you know how this would be structured.


interface Paginator
{
public function fetch(SDK $sdk, string $method, array $parameters = []): Generator;
public function hasNext(): bool;
}

Now, let’s look at an implementation:


final class Pagination implements Paginator
{
public function __construct(
private readonly int $perPage,
private array $pagination = [],
) {}
public function fetch(SDK $sdk, string $method, array $parameters = []): Generator
{
foreach ($this->fetch($sdk, $method, $parameters) as $value) {
yield $value;
}
while ($this->hasNext()) {
foreach ($this->fetchNext() as $value) {
yield $value;
}
}
}
public function hasNext(): bool
{
return $this->pagination['next'] !== null;
}
private function get(string $key): array
{
$pagination = $this->pagination[$key] ?? null;
if ($pagination === null) {
return [];
}
// Send the request and get the response.
$content = ResponseMediator::getContent($response);
if (! \is_array($content)) {
throw new RuntimeException('Pagination of this endpoint is not supported.');
}
$this->postFetch();
return $content;
}
private function postFetch(): void
{
// Get the last request from the SDK Client.
$this->pagination = $response === null
? []
: ResponseMediator::getPagination($response);
}
}

You do of course so something a little simpler if you need to, but in general this should work for you. The Response Mediator class is a utility class that I would sometimes use to simplify the working with API data. Let’s move onto how we might actually send some requests now though.

Sending Data through our SDK

One of the final stepping stones to building a good SDK, is figuring out how we want to create and update potential resources. In our example of an e-commerce API, the likelihood of creating a product object via API is extremely low. Typically you would use a provided admin dashboard. So, for this next example we are going to focus on the Customer resource. When a user registers through your platform, you want to create a customer resource on the e-commerce API, so that if the authenticated user orders anything - they will be able to link to the correct customer quickly and easily. We will look at creating a new customer next.

There are a few options, as always, when creating resources through an SDK. You can either:

  • Send a validated array through to the SDK
  • Send another Data Transfer Object specific to the request through to the SDK

My personal preference here is to use DTOs and then let the SDK handle sending this in the correct format. It allows a more strongly typed approach, and puts all of the control in the hands of the SDK - which minimizes potential risk.


final readonly class CreateCustomer
{
public function __construct(
public string $name,
public string $email,
public string $referrer,
) {}
public static function make(array $data): CreateCustomer
{
$mapper = new ObjectMapperUsingReflection();
return $mapper->->hydrateObject(
className: self::class,
payload: $data,
);
}
}

Just like the Product DTO, we add a static make method using the object mapper to create the object itself. Let’s now design the resource.


final readonly class CustomerResource
{
use CanCreateRequests;
public function __construct(
private SDK $sdk,
) {}
public function create(CreateCustomer $customer)
{
$request = $this->request(
method: Method::POST,
uri: '/customers',
);
// attach the customer as a stream.
}
}

We now need to work with our trait again, so that we can work with sending and using data.


trait CanCreateRequests
{
// Other method...
public function attachPayload(RequestInterface $request, string $payload): RequestInterface
{
return $request->withBody(
Psr17FactoryDiscovery::findStreamFactory()->createStream(
content: $payload,
);
);
}
}

What we are doing here is passing through the request we are building, and the stringified version of the payload. Again, we can use auto-discovery to detect what HTTP Stream Factory is installed, then create a stream from the payload and attach it to the request as its body.

We need a way to quickly and easily serialize the data from our DTOs to send through to create a stream. Let’s look at the DTO for creating a customer again.


final readonly class CreateCustomer
{
public function __construct(
public string $name,
public string $email,
public string $referrer,
) {}
public function toString(): string
{
return (string) json_encode(
value: [
'name' => $this->name,
'email' => $this->email,
'referrer' => $this->referrer,
],
flags: JSON_THROW_ON_ERROR,
);
}
public static function make(array $data): CreateCustomer
{
$mapper = new ObjectMapperUsingReflection();
return $mapper->->hydrateObject(
className: self::class,
payload: $data,
);
}
}

Now let’s go back to the implementation.


final readonly class CustomerResource
{
use CanCreateRequests;
public function __construct(
private SDK $sdk,
) {}
public function create(CreateCustomer $customer)
{
$request = $this->request(
method: Method::POST,
uri: '/customers',
);
$request = $this->attachPayload(
request: $request,
payload: $customer->toString(),
);
try {
$response = $this->sdk->client()->sendRequest(
request: $request,
);
} catch (Throwable $exception) {
throw new FailedToCreateCustomer(
message: 'Failed to create customer record on the API.',
previous: $exception,
);
}
// Return something that makes sense to your use case here.
}
}

So, we can quickly and easily create and send data. A typical usage of this in a Laravel application, would be to leverage the events system - listening for something like the Registered event to be fired:


final readonly class CreateNewCustomer
{
public function __construct(
private SDK $sdk,
) {}
public function handle(Registered $event): void
{
try {
$this->sdk->customers()->create(
customer: CreateCustomer::make(
data: [
'name' => $event->name,
'email' => $event->email,
'referrer' => $event->referrer,
],
),
);
} catch (Throwable $exception) {
Log::error('Failed to create customer record on API', ['event' => $event]);
throw $exception;
}
}
}

Quite clean and easy to use I am sure you would agree. The only improvement I would potentially suggest here is to use a dispatchable job or event-sourcing style system here, something that would allow you to replay the attempt - giving you the opportunity to fix and retry.

Summary

As you can see from this tutorial, building an SDK for your API isn’t overly tricky - but there are a lot of things to think about. With developer experience being a key factor in the success of your SDK, you need to make sure you think about that alongside the technical requirements that your SDK has.

At Speakeasy we have carefully designed how SDKs should work in each language we support, allowing you to follow a similar approach to the above without having to write a single line of code. Instead it will use your OpenAPI specification to generate a robust, well tested, and developer friendly SDK for your API. Even better, it will take less time than waiting for a pizza delivery. Now, I have always been against auto-generated SDKs, especially when you see some of the examples out there. However, what Speakeasy does is a completely different approach that guarantees better success and developer experience. Instead you can focus on building the best API and OpenAPI specification you can - and let us focus on providing you with a great SDK in multiple languages.

CTA background illustrations

Speakeasy Changelog

Subscribe to stay up-to-date on Speakeasy news and feature releases.