Exploring developer experience with PHP, public APIs, and beer

php

1,127 views

Exploring developer experience with PHP, public APIs, and beer blog meme

I'm back on my bull... stuff (as the kids say) and decided that it was time to jump back into a project of some sort to keep my sanity. I've been chasing an almost one-year-old around the house lately and needed a little reprieve in the form of writing some PHP on my epic quest to become the internet's token PHPman. Coincidentally, I've also been scouring the Open Brewery DB API and decided to bring holy matrimony to two of my loves in PHP and craft beer. The culmination of the two resulted in a package I published to Packagist, and I thought it would be fun to write about a rather pleasant DX I strapped together that made writing PHP an absolute blast (I hope that's not the first time that sentence has been said on the internet).

Liquid motivation

If you haven't noticed, I write about beer quite a bit here mostly in the form of code examples. One of my favorite ways to really dive deep into a language is to build a library of some sort, and it was time to scratch that itch with PHP. After sifting through a bunch of great libraries written by PHP folks much more versed in the language than me, I stumbled upon Nuno Maduro's library providing a PHP client (stop reading this and go give it a star!) for the Open AI API. I enjoyed the style that the library was written in and took a lot of influence from it while building my PHP client for Open Brewery DB.

A few things stood out to me as a journeyman PHP'er while building my simple API wrapper, and I thought it would be fun to highlight a few things I enjoyed while honing in my developer experience and building my first PHP package.

B.Y.O.C. (Bring Your Own Client)

PSR-18 calls for HTTP client implementations to adhere to a strict contract. Doing so allows consumers of the library to bring their favorite HTTP client without library internals causing compatibility issues. Whether the client provided is Guzzle or Symfony's standalone HTTP client, the library's only assumption is that it's a PSR-18 compliant client. This allows for a lot of flexibility when instantiating a client, even allowing consumers to configure their own client in a fluent builder-like fashion:

<?php

declare(strict_types=1);

namespace OpenBreweryDb;

use Http\Discovery\Psr18ClientDiscovery;
use OpenBreweryDb\Http\Connector;
use OpenBreweryDb\ValueObjects\Connector\BaseUri;
use OpenBreweryDb\ValueObjects\Connector\Headers;
use OpenBreweryDb\ValueObjects\Connector\QueryParams;
use Psr\Http\Client\ClientInterface;

/**
 * Client builder/factory for configuring the API connector to Open Brewery DB.
 */
final class Builder
{
    /**
     * The HTTP client for the requests.
     */
    private ?ClientInterface $httpClient = null;

    // ...other configurations

    /**
     * Sets the HTTP client for the requests. If no client is provided the
     * factory will try to find one using PSR-18 HTTP Client Discovery.
     */
    public function withHttpClient(ClientInterface $client): self
    {
        $this->httpClient = $client;

        return $this;
    }

    // ...other builder options

    /**
     * Creates a new Open Brewery DB client based on the provided builder options.
     */
    public function build(): Client
    {
        $headers = Headers::create();

        // For any default headers configured for the client, we'll add those to each outbound request
        foreach ($this->headers as $name => $value) {
            $headers = $headers->withCustomHeader($name, $value);
        }

        $baseUri = BaseUri::from(Client::API_BASE_URL);
        $queryParams = QueryParams::create();

        // As with the headers, we'll also include any query params configured on each request
        foreach ($this->queryParams as $name => $value) {
            $queryParams = $queryParams->withParam($name, $value);
        }

        $client = $this->httpClient ??= Psr18ClientDiscovery::find();
        $connector = new Connector($client, $baseUri, $headers, $queryParams);

        return new Client($connector);
    }
}

Even more awesome is the ability to have composer autodiscover the HTTP client a user has installed in their application with the help of the php-http/discover package. From the code above, if a consumer of the library decides to use the client of their choice, it's as simple as wiring it up whenever they want to use it:

<?php

declare(strict_types=1);

use OpenBreweryDb\OpenBreweryDb;

require_once __DIR__.'/../vendor/autoload.php';


// Using the client builder with PSR HTTP client autodiscovery,
// we can use any PSR-17 compliant client library. This makes
// it relatively simple to swap providers without having to
// change the internal API connections and implementation. In
// the following example, Guzzle's HTTP client is used, though
// this could easily be swapped out for Symfony's client as well.

$guzzleClient = new GuzzleHttp\Client([
    'timeout' => 5,
]);

$openBreweryDbClient = OpenBreweryDb::builder()
    ->withHttpClient($guzzleClient)
    ->withHeader('foo', 'bar')
    ->build();

// Get a list of breweries, based on all types of different search criteria
$breweries = $openBreweryDbClient->breweries()->list([
    'by_city' => 'Sacramento',
]);
var_dump($breweries);

// Retrieve various metadata about breweries from the API
$metadata = $openBreweryDbClient->breweries()->metadata();
var_dump($metadata);

// Get a random brewery with a specified page size
$randomBrewery = $openBreweryDbClient->breweries()->random(5);
var_dump($randomBrewery);

// Since we're not limited to a specific HTTP client, we can mix and match
// depending on what client you have installed or want to use.
$symfonyClient = (new Symfony\Component\HttpClient\Psr18Client())->withOptions([
    'headers' => ['symfony' => 'is-awesome'],
]);

$openBreweryDbClientWithSymfony = OpenBreweryDb::builder()
    ->withHttpClient($symfonyClient)
    ->withHeader('foo', 'bar')
    ->build();

// Get a list of breweries, based on all types of different search criteria
$breweries = $openBreweryDbClientWithSymfony->breweries()->list([
    'by_city' => 'Sacramento',
]);
var_dump($breweries);

// Retrieve various metadata about breweries from the API
$metadata = $openBreweryDbClientWithSymfony->breweries()->metadata();
var_dump($metadata);

// Get a random brewery with a specified page size
$randomBrewery = $openBreweryDbClientWithSymfony->breweries()->random(5);
var_dump($randomBrewery);

In the case consumers don't explicitly provide their client, autodiscovery will kick in and search for a PSR-18 compliant client and use that to fulfill the contractual obligation of PSR-18. Neat!

Immutable response arrays

One of my favorite features of PHP is associative arrays or PHP's version of anonymous objects which are quite handy and akin to their counterparts in JavaScript or C#. One of the more annoying things when writing an API client, especially one not officially associated with the API it's wrapping, is keeping up with data contracts from the various endpoints. I found that treating JSON responses like associative arrays allows the API provider to change the shape of the response without breaking the connective tissue between the library code (to an extent, of course). Because responses can be massaged into associative arrays, it allows all the niceties of the array_* methods that exist in PHP to be bolted onto the response objects themselves.

A simple way to achieve this behavior is to provide a trait that implements the offset methods for an array:

<?php

declare(strict_types=1);

namespace OpenBreweryDb\Responses\Concerns;

use BadMethodCallException;
use OpenBreweryDb\ValueObjects\Connector\Response;

/**
 * Allows API responses to be treated as arrays, allowing for access through an index to check for properties.
 *
 * @template-covariant TArray of array
 *
 * @mixin Response<TArray>
 *
 * @internal
 */
trait ArrayAccessible
{
    /**
     * {@inheritDoc}
     */
    public function offsetExists(mixed $offset): bool
    {
        return array_key_exists($offset, $this->toArray());
    }

    /**
     * {@inheritDoc}
     */
    public function offsetGet(mixed $offset): mixed
    {
        return $this->toArray()[$offset];
    }

    /**
     * {@inheritDoc}
     */
    public function offsetSet(mixed $offset, mixed $value): never
    {
        throw new BadMethodCallException('Responses are immutable. Values are not allowed to be set on responses.');
    }

    /**
     * {@inheritDoc}
     */
    public function offsetUnset(mixed $offset): never
    {
        throw new BadMethodCallException('Responses are immutable. Values are not allowed to be removed on responses.');
    }
}

With the ArrayAccessible trait in place, we can define contracts that each API response needs to adhere to:

<?php

declare(strict_types=1);

namespace OpenBreweryDb\Contracts;

use ArrayAccess;
use OpenBreweryDb\Contracts\Concerns\Arrayable;

/**
 * Response contracts provide a set of methods allowing responses to be interacted with in a PHP array-like manner.
 *
 * @template TArray of array
 *
 * @extends ArrayAccess<key-of<TArray>, value-of<TArray>>
 * @extends Arrayable<TArray>
 *
 * @internal
 */
interface ResponseContract extends Arrayable, ArrayAccess
{
    /**
     * @param  key-of<TArray>  $offset
     */
    public function offsetExists(mixed $offset): bool;

    /**
     * @template TOffsetKey of key-of<TArray>
     *
     * @param  TOffsetKey  $offset
     * @return TArray[TOffsetKey]
     */
    public function offsetGet(mixed $offset): mixed;

    /**
     * @template TOffsetKey of key-of<TArray>
     *
     * @param  TOffsetKey|null  $offset
     * @param TArray[TOffsetKey] $value
     */
    public function offsetSet(mixed $offset, mixed $value): never;

    /**
     * @template TOffsetKey of key-of<TArray>
     *
     * @param  TOffsetKey  $offset
     */
    public function offsetUnset(mixed $offset): never;
}

By extending ArrayAccess, all the implementors of ResponseContract can use the ArrayAccessible trait to satisfy the methods required by the contract. With the help of PHPDoc, we can type constrain the generic response data to be covariant over PHP's native array, or more simply put, enforce our inheritors to provide something that can be traced back to an associative array type. Take for example a metadata response from Open Brewery DB which will include some summary-level information about the available breweries within the dataset:

// GET https://api.openbrewerydb.org/v1/breweries/meta
{
    "total": "8247",
    "page": "1",
    "per_page": "50"
}

We can encapsulate this response with a MetadataResponse:

<?php

declare(strict_types=1);

namespace OpenBreweryDb\Responses\Breweries;

use OpenBreweryDb\Contracts\ResponseContract;
use OpenBreweryDb\Responses\Concerns\ArrayAccessible;
use Override;

/**
 * Metadata response representing only aggregate data about breweries based on the provided query.
 *
 * @see https://openbrewerydb.org/documentation#metadata
 *
 * @implements ResponseContract<array<int, array{total: string, page: string, per_page: string}>>
 */
final readonly class MetadataResponse implements ResponseContract
{
    /**
     * @use ArrayAccessible<array<int, array{total: string, page: string, per_page: string}>>
     */
    use ArrayAccessible;

    /**
     * @param  array<int, array{total: string, page: string, per_page: string}>  $data
     */
    private function __construct(public array $data)
    {
    }

    /**
     * @param  array<int, array{total: string, page: string, per_page: string}>  $attributes
     */
    public static function from(array $attributes): self
    {
        return new self($attributes);
    }

    /**
     * {@inheritDoc}
     */
    #[Override]
    public function toArray(): array
    {
        return $this->data;
    }
}

Now anytime we make a call to the API, we'll have implicitly typed immutable responses by default:

<?php

declare(strict_types=1);

require_once __DIR__.'/../vendor/autoload.php';

use OpenBreweryDb\OpenBreweryDb;

$client = OpenBreweryDb::client();

// Retrieve various metadata about breweries from the API
$metadata = $client->breweries()->metadata();
var_dump($metadata);

# If we're using an editor with some form of PHP LSP, we'll have autocomplete by default
$total = $metadata['total'];

# This will throw an exception since responses are immutable
$metadata['total'] = 42069; // BAD! Throws an exception!

Responses will also have the added benefit of intellisense and immutable protection, so no monkeying around with the source of truth.

Strict PHP

Okay, now for my favorite part of PHP. I've been programming professionally for almost a decade now solely with static-typed languages, and duck typing is not something I'm ready to embrace just yet (I'm sure I'll come around to Ruby eventually). I wanted the same level of type-checking ~~pain~~ assistance I get in other languages like Rust and C# but in PHP. Luckily, the solution is PHPStan turned up to the max with the additional strict analysis plugin. My PHPStan configuration for the library is pretty simple:

includes:
    - vendor/phpstan/phpstan-strict-rules/rules.neon

parameters:
    level: max
    paths:
        - src

And to keep myself honest, I like to wrap my linting commands within a daemon of sorts to rerun anytime I make source code changes. There are a bunch of awesome tools out there (watchman, fsnotify) though my current favorite is entr. Anytime I want to continuously check that my code is safe and sane, it's just a matter of watching for file changes within my src/ directory:

find src/ | entr -s 'composer run lint'
> ./vendor/bin/phpstan analyze
Note: Using configuration file /Users/jmckenzie/workspace/php/openbrewerydb-php-client/phpstan.neon.dist.

 26/26 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%



 [OK] No errors

find will list all source files within a directory, where we'll then pipe those into the entr command that watches for any of those files to change and will run my lint script from my composer.json file:

{
    // ...other stuff
    "scripts": {
        // ...other scripts
        "lint": "./vendor/bin/phpstan analyze"
    }
}

Now anytime I make file changes, the linter runs and allows me to sleep soundly knowing I haven't placed any landmines in the code (yet...).

Formatting is not an opinion

Working in tandem with max-level PHPStan and strict rules enabled, I also chose to use Nuno's preset for extra strict Pint rules. I'm a huge fan of incredibly opinionated formatters as it takes one more decision away from me while writing code that has nothing to do with the code itself, allowing me to simply write the features I care about and leave the grunt work of making the code look pretty to a standardized tool. As a newfound Laravelian, I like to use Pint in my PHP code for that exact reason. This allows me to write garbage code and have Pint clean it up for me whenever I'm ready to commit.

I've worked on many large-scale projects over the years, and one of my biggest pet peeves is inconsistency among developers all working on the same codebase. Having cut my teeth early in my career on eye-bleeder Java dating back to 1996, taking the decision away from me what the code should look like is a godsend from above. On the other hand, years of arm wrestling ESLint and Prettier has turned me into quite the curmudgeon when it comes to code quality, oftentimes being the first tools I set up when jumping into an existing JS/TS codebase without any enforcement standards. Pint scratches this itch for PHP, being an opinionated wrapper PHP-CS-Fixer.

Though somewhat controversial, I'm also a fan of git hooks, namely pre-commit and pre-push. To keep myself honest when pushing new code, I'll also run my formatters on pre-commit, something like:

#!/bin/bash

composer run fmt

Where the composer script will look like:

{
    // ...other stuff
    "scripts": {
        // ...other scripts
        "fmt": "./vendor/bin/pint -v"
    }
}

I like to see what files are formatted with any formatting tool, and a quick -v covers that. With my pre-push hooks, I usually like to test for lints and just to make sure I'm not pushing any potential new bugs that have nothing to do with the feature code itself. This usually boils down to running a quick ./vendor/bin/pint --test within the hook just to verify all the staged files pass the visually pleasing test.

Watch testing with Pest

To fit a few more keywords for SEO purposes in this post, TDD (the one and only time I'll mention it) is a breeze with Pest. Being able to run tests anytime I make changes gives me the confidence to fearlessly refactor my code without having to worry too much about introducing breaking changes. I see a lot of developers in the wild that either:

  • Don't test their code at all, with the usual excuse of "it takes too long" or "it doesn't provide immediate value"
  • Run their tests too late

Focusing on the second bullet, I've fallen victim more times than I care to admit of going down the refactoring rabbit hole spending hours cleaning things up, only to find I've busted all of my tests leading to another chunk of time spent updating said tests. Simply running a quick ./vendor/bin/pest --watch in the terminal and saving often, I'm able to quickly pivot off of a bout of refactoring and into test cleanup as the breakages occur rather than when it's too late. Probably an obvious point for most of us, but a valuable insight nonetheless.

Enforcing architecture early

Harkening back to my Java days, I've always found architecture testing to be incredibly valuable, especially when jumping into a new codebase and getting up to speed on the lay of the codebase land. I used a lot of ArchUnit back in the day, and to my pleasant surprise, Pest offers a lightweight equivalent with their built-in architecture testing assertions. Tapping into the API, I was able to easily enforce standards of where things should be placed based on their use case, what constraints should be placed on class files, etc. I ended up with a simple architecture assertion spec that looks something like:

<?php

declare(strict_types=1);

namespace Tests;

test('All source files are strictly typed')
    ->expect('OpenBreweryDb\\')
    ->toUseStrictTypes();

test('All tests files are strictly typed')
    ->expect('Tests\\')
    ->toUseStrictTypes();

test('Value objects should be immutable')
    ->expect('OpenBreweryDb\\ValueObjects\\')
    ->toBeFinal()
    ->and('OpenBreweryDb\\ValueObjects\\')
    ->toBeReadonly();

test('Responses should be immutable')
    ->expect('OpenBreweryDb\\Responses\\Breweries\\')
    ->toBeFinal()
    ->and('OpenBreweryDb\\Responses\\Breweries\\')
    ->toBeReadonly();

test('Contracts should be abstract')
    ->expect('OpenBreweryDb\\Contracts\\')
    ->toBeInterfaces();

test('All Enums are backed')
    ->expect('OpenBreweryDb\\Enums\\')
    ->toBeStringBackedEnums();

At the top level, all of the following are enforced now:

  • All source and test files are strictly typed with declare(strict_types=1)
  • Value objects are immutable by default (though value equality is not enforced)
  • API response objects are immutable by default
  • Enums are string-backed

While not an exhaustive list of what architecture testing provides, just a few simple rules for myself are all I needed. There are assertions for enforcing certain namespaces only depend on explicitly targeted namespaces, certain classes are invokable by default, etc. A bit outside the scope of my simple API wrapper, but helpful altogether.

Just run the tasks

Lastly, one of my favorite things to employ the use of in any application or library I might be working is just which markets itself as just a task runner. I'm not smart enough to understand Makefiles, and with just, defining a justfile is simple and straight forward. In the case of working on a library, I'm able to add in all the random commands I'll run from time to time and have a single syntax for my brain to reactively impulse to anytime I crack open the terminal:

default: lint

# check types on any file change
lint:
    find src/ tests/ | entr -s 'composer run lint'

# run tests in parallel
test:
    find src/ tests/ | entr -s 'composer run test'

# run refactors
fmt:
    find src/ tests/ | entr -s 'composer run refactor'

Now anytime I'm actively working on some code or a new feature, I'll throw a just lint out there and crank away.

TL;DR

With the amount of dunking we do on PHP as a collective slice of the internet as developers, building a sensible DX around the language for any project is fairly easy to do and in my personal opinion has made working with PHP a ton of fun. While I wasn't around for the early PHP days (though I've heard the horror stories), modern PHP feels right at home with the other languages I work with on a daily basis (primarily being a C# and TypeScript these days). I'm having an absolute blast looking for new things to write in PHP, and I'm sure Packagist is missing a library somewhere for some obscure task that I'll stumble upon one of these days, giving me more of a reason to share my questionable code with the internet.

For those interested, all the source code for the library can be found on my GitHub.

Until next time, friends!

Not currently listening