If you just want to install it without reading the whole article, you can install it via Composer: rikudou/aws-sdk-phpstan.

When using PHPStan alongside AWS, you end up making a lot of type assertions, because the automatically generated AWS SDK doesn’t strictly define types, so everything defaults to mixed. Fortunately, the SDK package includes the source data used to generate itself, so I reused that information to create a PHPStan extension. This extension provides precise type definitions, catches all type-related errors for return types, and allows you to remove those otherwise unnecessary type assertions.

How It’s Made

As mentioned earlier, if all you want is to install and use the package, you don’t really need this article. But if you want a closer look at how it works, read on.

The first step was to make the Result class (which is returned by all API calls) generic by providing a custom stub—particularly for its get() method:

/**
 * @template T of array<string, mixed>
 * @implements ResultInterface<T>
 */
class Result implements ResultInterface, MonitoringEventsInterface
{
    /**
     * @template TKey of (key-of<T>|string)
     * @param TKey $key
     * @return (TKey is key-of<T> ? T[TKey] : null)
     */
    public function get(string $key): mixed {}
}

The class itself is generic, constrained to an array. The get() method is also made generic based on the key. If the key is a known key of T, the method returns its corresponding value; otherwise, it returns null. Essentially, if the response type expects the property, it’s returned—if not, null is returned.

The Individual Clients

All the client classes are generated from the type definitions in the src/data directory of the official AWS SDK for PHP. Each client’s definitions come in two files, such as:

(These map one-to-one with the PHP client methods. You’ll notice that the actual PHP client just uses __call() for these methods.)

For example, in the JSON definition for S3Client, you might see:

{
    "GetObject":{
      "name":"GetObject",
      "http":{
        "method":"GET",
        "requestUri":"/{Bucket}/{Key+}"
      },
      "input":{"shape":"GetObjectRequest"},
      "output":{"shape":"GetObjectOutput"},
      "errors":[
        {"shape":"NoSuchKey"},
        {"shape":"InvalidObjectState"}
      ],
      "documentationUrl":"http://docs.amazonwebservices.com/AmazonS3/latest/API/RESTObjectGET.html",
      "httpChecksum":{
        "requestValidationModeMember":"ChecksumMode",
        "responseAlgorithms":[
          "CRC64NVME",
          "CRC32",
          "CRC32C",
          "SHA256",
          "SHA1"
        ]
      }
    }
}

And in PHP:

use Aws\S3\S3Client;

$client = new S3Client([]);
$object = $client->getObject([
    'Bucket' => 'test',
    'Key' => 'test',
]);

In reality, these methods don’t actually exist in the client class; they’re invoked through __call() under the hood.

Going back to the JSON definitions, each operation has an input and an output shape. The package currently only focuses on the output shape, although I plan to add input shape support in the future. For the GetObjectOutput, the relevant shape might be:

{
    "GetObjectOutput":{
      "type":"structure",
      "members":{
        "Body":{
          "shape":"Body",
          "streaming":true
        },
        "DeleteMarker":{
          "shape":"DeleteMarker",
          "location":"header",
          "locationName":"x-amz-delete-marker"
        },
        "AcceptRanges":{
          "shape":"AcceptRanges",
          "location":"header",
          "locationName":"accept-ranges"
        },
        "Expiration":{
          "shape":"Expiration",
          "location":"header",
          "locationName":"x-amz-expiration"
        },
        "Restore":{
          "shape":"Restore",
          "location":"header",
          "locationName":"x-amz-restore"
        },
        "LastModified":{
          "shape":"LastModified",
          "location":"header",
          "locationName":"Last-Modified"
        }
    }
}

(Note: The actual shape is larger, but I’ve omitted some fields to keep this article shorter.)

Generating Type Extensions

PHPStan lets you add extensions that generate return types based on the method call and its input parameters. I decided to take that approach, though there are other possibilities (such as generating a stub file for each client).

For every client, a class like {ShortAwsClientClassName}TypeExtension is generated, for example, S3ClientReturnTypeExtension. The isMethodSupported() method just checks if the method name matches one of the operations defined in the JSON file. Then there’s a getTypeFromMethodCall() method that uses a match expression to call a private method of the same name.

Those private methods return PHPStan types derived from the shapes in the JSON data. The generator supports lists, maps, nested structures, binary blobs, date/time objects, enums, and simple types (strings, booleans, integers, floats), including unions, nested arrays, and more.

If you want to dive into the code:

As a final touch, the extension.neon file (which registers extensions with PHPStan) is populated automatically with each generated class.

Performance

The performance isn’t ideal if you include every single client class by default—which is understandable considering there are around 400 classes, each containing thousands of lines. Most projects likely won’t use all 400 AWS services in one codebase. That’s why I provided a generation script as a Composer binary, along with support for specifying only the clients you need by updating your composer.json:

{
  "extra": {
    "aws-sdk-phpstan": {
      "only": [
        "Aws\\S3\\S3Client",
        "Aws\\CloudFront\\CloudFrontClient"
      ]
    }
  }
}

After that, run vendor/bin/generate-aws-phpstan to regenerate the classes. The script deletes all existing type extensions first, then generates only for S3Client and CloudFrontClient. It also updates the extensions.neon file with just those extensions. With only a few extensions active, there’s no noticeable slowdown in PHPStan.

You can also leverage Composer’s script events to run the binary automatically:

{
  "extra": {
    "aws-sdk-phpstan": {
      "only": [
        "Aws\\S3\\S3Client",
        "Aws\\CloudFront\\CloudFrontClient"
      ]
    }
  },
  "scripts": {
    "post-install-cmd": [
      "generate-aws-phpstan"
    ],
    "post-update-cmd": [
      "generate-aws-phpstan"
    ]
  }
}

Ideally, the official SDK itself would include type definitions (it shouldn’t be too difficult, given they already generate the SDK from these JSON files). In the meantime, though, I’m pretty happy with how this little project turned out.