Skip to content

How-to guideFor module developersDrupal 11

Call external APIs

Drupal ships a fully configured Guzzle HTTP client as the http_client service. This page shows you how to call third-party APIs with it, then how to make those calls production-grade: failures handled, credentials out of config, and slow calls moved off the page request.

Your module reads from and writes to an external HTTP API reliably: requests time out fast, errors degrade gracefully instead of taking pages down, secrets live in settings.php, and bulk synchronization happens in the background. This page is about outbound HTTP. For exposing your Drupal data to other applications (inbound), start with JSON:API basics.

The samples come from the examples project’s module_howtos_b_demo module, which prefixes its settings and queue names with docs_demo. Replace that prefix, and my_module in the hand-written YAML, with your module’s machine name.

Wrap your API in a dedicated service class and inject the client. With autowiring, a GuzzleHttp\ClientInterface type hint resolves to http_client through the alias core defines in core.services.yml; without autowiring, pass '@http_client' explicitly:

my_module.remote_articles:
class: Drupal\my_module\RemoteArticleClient
arguments: ['@http_client', '@logger.channel.my_module']

Do not instantiate Guzzle yourself with new Client(). The service comes from ClientFactory, which bakes in settings you want: TLS certificate verification against the operating system’s CA bundle, a 30 second default timeout, a Drupal User-Agent, environment proxy variables disabled, and any site-wide overrides from $settings['http_client_config'].

One class, the whole everyday pattern: request options, a fast timeout, JSON decoding, and error handling.

src/RemoteArticleClient.php
/**
* Talks to an external articles API with Drupal's HTTP client.
*
* The injected http_client service is a Guzzle client that Drupal has
* already configured: TLS verification on, a 30 second default timeout,
* and a Drupal User-Agent header.
*/
class RemoteArticleClient {
/**
* The base URL of the remote API.
*/
protected const API_BASE = 'https://api.example.com';
/**
* Constructs the client.
*
* @param \GuzzleHttp\ClientInterface $httpClient
* The http_client service.
* @param \Psr\Log\LoggerInterface $logger
* This module's logger channel.
*/
public function __construct(
protected readonly ClientInterface $httpClient,
protected readonly LoggerInterface $logger,
) {}
/**
* Fetches published article summaries from the remote API.
*
* @return array<int, array<string, mixed>>
* Decoded article summaries, or an empty array on failure.
*/
public function fetchSummaries(): array {
try {
$response = $this->httpClient->request('GET', static::API_BASE . '/articles', [
'query' => ['status' => 'published'],
'headers' => [
'Accept' => 'application/json',
// The credential comes from settings.php, never from config.
'Authorization' => 'Bearer ' . Settings::get('docs_demo_api_token', ''),
],
// Fail fast: a slow API must not hold a page request (or a
// whole cron run) hostage for the default 30 seconds.
'timeout' => 10,
]);
return Json::decode((string) $response->getBody()) ?? [];
}
catch (GuzzleException $e) {
// 4xx and 5xx responses, timeouts, and DNS failures all land
// here. Log and degrade instead of taking the page down.
$this->logger->error('Fetching remote articles failed: @message', [
'@message' => $e->getMessage(),
]);
return [];
}
}

The decisions in that code, made explicit:

  • Always set a timeout per request when the default 30 seconds is wrong for the context. A page that blocks on a third party for 30 seconds is an outage; so is a cron run that spends its whole budget on one hung request.
  • Catch GuzzleException, the interface every Guzzle failure implements. With the default http_errors behavior, 4xx and 5xx responses throw (ClientException and ServerException, both subtypes of RequestException carrying the response); network-level failures throw ConnectException. Catch the broad interface at the boundary and return a safe value.
  • Decode with Json::decode() rather than bare json_decode(), for consistent array output and error behavior.

API keys never belong in configuration. Config is exportable by design: drush config:export writes it to YAML in your repository, it travels through every environment, and anyone with config access can read it. The deploy pipeline would smear your production secret across dev machines and CI logs.

settings.php is the opposite: it is read only on the server, and each environment can supply its own values. The file itself is usually committed, so never paste the literal secret into it. Read the secret from an environment variable (or an uncommitted settings.local.php) in settings.php, and read the setting in code with Settings::get(), as the client above does:

// Fragment: in settings.php (or settings.local.php / a hosting secret).
$settings['docs_demo_api_token'] = getenv('REMOTE_ARTICLES_TOKEN');

Anything a site builder should edit in the UI (the API’s base URL, a sync interval) can stay in regular config; the rule covers secrets, not settings.

A remote API multiplies every reliability problem at sync time: hundreds of calls, any of which can fail. The production pattern is to enqueue one item per call and let a queue worker process them at cron, item by item, with retries. The write side of the client throws instead of catching, so the worker can decide:

src/RemoteArticleClient.php
/**
* Pushes one article to the remote API.
*
* @param \Drupal\node\NodeInterface $article
* The article to push.
*
* @throws \GuzzleHttp\Exception\GuzzleException
* Deliberately not caught here: the queue worker that calls this
* method turns failures into retries.
*/
public function push(NodeInterface $article): void {
$this->httpClient->request('POST', static::API_BASE . '/articles', [
'json' => [
'id' => $article->uuid(),
'title' => $article->label(),
],
'headers' => [
'Authorization' => 'Bearer ' . Settings::get('docs_demo_api_token', ''),
],
'timeout' => 10,
]);
}

The worker turns transport failures into queue semantics: an unreachable API suspends the queue until the next cron run; any other failure leaves the item queued for retry.

src/Plugin/QueueWorker/ArticleSyncWorker.php
/**
* {@inheritdoc}
*/
public function processItem($data): void {
$node = $this->entityTypeManager->getStorage('node')->load($data['nid']);
if (!$node instanceof NodeInterface) {
// Deleted since it was queued. Returning normally tells the queue
// the item is done, so it is discarded.
return;
}
try {
$this->client->push($node);
}
catch (ConnectException $e) {
// The whole API is unreachable, so processing more items now is
// pointless: suspend the queue until the next cron run. This item
// is released, not lost.
throw new SuspendQueueException('The remote API is unreachable.', 0, $e);
}
// Any other exception bubbles up: cron logs it and leaves the item
// in the queue for a retry once its lease expires.
}

Filling the queue is the cron hook’s job. The complete chain (cron enqueues changed articles, this worker pushes them) is on Background processing, including the full worker plugin skeleton.

  1. Call your client interactively:

    Terminal window
    drush php:eval "print_r(\Drupal::service('my_module.remote_articles')->fetchSummaries());"

    You see the decoded array, or an empty array plus a log entry if the API is unreachable.

  2. Check the failure path deliberately: point the client at an unreachable host name, run the command again, and confirm drush watchdog:show contains your error with the message placeholder filled in.

  3. For the async path, create or edit an article, run drush core:cron, and watch drush queue:list report the sync queue draining.

  • Different client options for one integration. Inject http_client_factory (ClientFactory) and call fromOptions() with a base URI, default headers, or a different timeout; you get a separate Guzzle instance that still inherits Drupal’s hardened defaults.
  • Site-wide client configuration. $settings['http_client_config'] in settings.php adjusts every request the site makes; the documented use in default.settings.php is routing outbound traffic through a corporate proxy.
  • Cross-cutting behavior with middleware. A service tagged http_client_middleware adds a Guzzle middleware to the shared handler stack: request signing, instrumentation, or caching for every consumer of http_client. See the tag list on Services and events.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.