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.
Prerequisites
Section titled “Prerequisites”- A custom module; the first tutorial part gets you one.
- Services and dependency injection for the injection pattern.
- For the async step, the queue concepts from Background processing.
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.
Inject the HTTP client
Section titled “Inject the HTTP client”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'].
Make requests and handle failures
Section titled “Make requests and handle failures”One class, the whole everyday pattern: request options, a fast timeout, JSON decoding, and error handling.
/** * 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
timeoutper 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 defaulthttp_errorsbehavior, 4xx and 5xx responses throw (ClientExceptionandServerException, both subtypes ofRequestExceptioncarrying the response); network-level failures throwConnectException. Catch the broad interface at the boundary and return a safe value. - Decode with
Json::decode()rather than barejson_decode(), for consistent array output and error behavior.
Keep credentials in settings.php
Section titled “Keep credentials in settings.php”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.
Move slow and bulk calls to a queue
Section titled “Move slow and bulk calls to a queue”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:
/** * 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.
/** * {@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.
Verify
Section titled “Verify”-
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.
-
Check the failure path deliberately: point the client at an unreachable host name, run the command again, and confirm
drush watchdog:showcontains your error with the message placeholder filled in. -
For the async path, create or edit an article, run
drush core:cron, and watchdrush queue:listreport the sync queue draining.
Variations
Section titled “Variations”- Different client options for one integration. Inject
http_client_factory(ClientFactory) and callfromOptions()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']insettings.phpadjusts every request the site makes; the documented use indefault.settings.phpis routing outbound traffic through a corporate proxy. - Cross-cutting behavior with middleware. A service tagged
http_client_middlewareadds a Guzzle middleware to the shared handler stack: request signing, instrumentation, or caching for every consumer ofhttp_client. See the tag list on Services and events.
Related
Section titled “Related”- Background processing owns the queue and cron machinery this page builds on.
- Logging covers the channels your client should report failures to.
- Authentication and CORS is the inbound counterpart for authenticating callers of your site.
- API topic:
integrating third-party applications.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.