Configure Guzzle via Dependency Injection

| 7 minutes symfony guzzle dependency injection tutorial

With Guzzle v7, its class GuzzleHttp\Client became annotated as @final as it will be a real final class in Guzzle v8. Extending Guzzle clients to enrich them with custom functionality or to pass configuration (e.g. API credentials) is now discouraged and static code analysis tools like PHPStan may report this as an error. Depending on how GuzzleHttp\Client is extended, migration may be cumbersome. I got your back, and I'll cover some common cases in this blog post.

Simple client with custom configuration

In a project, we heavily extended GuzzleHttp\Client for all our cases as we wanted to make use of dependency injection via the client's class names. Please see the example below how our clients were implemented.

We defined our class App\Client\GithubClient that extends GuzzleHttp\Client without any further logic:

// src/Client/GithubClient.php
namespace App\Client;

class GithubClient extends \GuzzleHttp\Client
{
}

The client App\Client\GithubClient is injected into the service App\Service\GithubService:

// src/Service/GithubService.php
namespace App\Service;

class GithubService
{
    public function __construct(
        private readonly GithubClient $client
    ) {
    }
}

The client App\Client\GithubClient is configured with the API key in the project's services.yaml file:

# src/config/services.yaml
services:
  App\Client\GithubClient
    class: App\Client\GithubClient
    arguments:
      $config:
        headers:
          Authorization: 'token %app.github.api_key%'

Migration time

This is the most simple way a Guzzle client may be configured. A client just takes some configuration (like the Authorization header from the example) above, and it's ready to use. The whole class can be replaced by a simple GuzzleHttp\Client instance, which is configured in our services.yaml file:

services:
  guzzle.client.github
    class: GuzzleHttp\Client
    arguments:
      $config:
        headers:
          Authorization: 'token %app.github.api_key%'

This requires another change! Since we are using GuzzleHttp\Client now, autowiring the correct Guzzle client via its class name does not work anymore.

Now, we have to configure our service to use the guzzle.client.github service explicitly:

services:
  App\Service\GithubService:
    class: App\Service\GithubService
    arguments:
      $client: '@guzzle.client.github'

Finally, we have to adjust the constructor of our service to expect an instance \GuzzleHttp\Client as argument:

// src/Service/GithubService.php
namespace App\Service;

class GithubService
{
    public function __construct(
        private readonly \GuzzleHttp\Client $client
    ) {
    }
}

This migration is pretty easy, the key changes are:

  • the service name is changed to guzzle.client.github
  • the class in the service definition is changed to GuzzleHttp\Client, as our custom client App\Client\GithubClient became obsolete
  • App\Service\GithubService is configured to use guzzle.client.github explicitly

Create complex client via factory

In some cases, our clients are more complex and need custom logic besides the configuration. A common use-case is passing an API key via a query parameter in the request URL, for example as required by Google Maps.

See an example of how our GoogleMapsClient was implemented before:

// src/Client/GoogleMapsClient.php
namespace App\Client;

class GoogleMapsClient extends \GuzzleHttp\Client
{
    public function __construct(array $config, string $apiKey)
    {
        $handlerStack = \GuzzleHttp\HandlerStack::create($config['handler'] ?? null);
        $config = array_merge($config, [
            'base_uri' => rtrim($config['base_uri'] ?? '', '/') . '/',
            'handler' => $handlerStack,
        ]);
        $handlerStack->unshift(\GuzzleHttp\Middleware::mapRequest(static function (\Psr\Http\Message\RequestInterface $request) use ($apiKey) {
            return $request->withUri(\GuzzleHttp\Psr7\Uri::withQueryValue($request->getUri(), 'key', $apiKey));
        }));

        parent::__construct($config);
    }
}

In this example, we create a GuzzleHttp\HandlerStack and add a middleware to it. The middleware is responsible for adding the key query parameter to the request. Reminder: We're extending \GuzzleHttp\Client which will not work anymore in the future.

Factories to the rescue

To be able to re-implement our custom logic, we use service factories to create our client. In this example, we're using an invokable factory:

// src/Factory/Guzzle/GoogleMapsClientFactory.php
namespace App\Factory\Guzzle;

class GoogleMapsClientFactory
{
    public function __invoke(array $config, string $apiKey): \GuzzleHttp\ClientInterface
    {
        $handlerStack = \GuzzleHttp\HandlerStack::create($config['handler'] ?? null);
        $config = array_merge($config, [
            'base_uri' => rtrim($config['base_uri'] ?? '', '/') . '/',
            'handler' => $handlerStack,
        ]);
        $handlerStack->unshift(\GuzzleHttp\Middleware::mapRequest(static function (RequestInterface $request) use ($apiKey) {
            return $request->withUri(\GuzzleHttp\Psr7\Uri::withQueryValue($request->getUri(), 'key', $apiKey));
        }));

        return new \GuzzleHttp\Client($config);
    }
}

The factory now creates an instance of GuzzleHttp\Client, containing GuzzleHttp\HandlerStack with the middleware that adds the key query parameter to the request.

We can use the created factory with the factory option in our service definition:

services:
  App\Factory\Guzzle\GoogleMapsClientFactory: ~

  guzzle.client.google_maps:
    class: GuzzleHttp\Client
    factory: '@App\Factory\Guzzle\GoogleMapsClientFactory'
    arguments:
      $config:
        base_uri: '%env(GOOGLE_MAPS_BASE_URI)%'
      $apiKey: '%env(GOOGLE_MAPS_API_KEY_PRIVATE)%'

The client can now get passed via dependency injection:

services:
  App\Service\GoogleMapsService:
    class: App\Service\GoogleMapsService
    arguments:
      $client: '@guzzle.client.google_maps'

The key changes are:

  • the service name is changed to guzzle.client.google_maps
  • a factory is introduced that takes care of creating a client instance
  • the class in the service definition is changed to GuzzleHttp\Client, as our custom client App\Client\GoogleMapsClient became obsolete
  • the service definition uses the introduced factory
  • App\Service\GoogleMapsService is configured to use guzzle.client.google_maps explicitly

Now, you have only one Guzzle client to rule them all and there's one step less before Guzzle v8 hits the road.

Header photo by Taylor Vick on Unsplash.

Previous Post