0% прочитано

Adapter Pattern in Laravel: Isolating an External HTTP API Behind IndexNowClientInterface

Прикладний шар ICanUp має лише передати список публічних URL і не знати нічого про endpoint, API key, формат HTTP-запиту чи Laravel Http facade. У статті я показую, як `IndexNowClientInterface` став портом для прикладного коду, а `IndexNowHttpClient` адаптує цей простий контракт до зовнішнього API IndexNow.

29 вересня 2026 р. 10 хв читанняLaravel

When ICanUp started notifying search engines automatically about new and changed public URLs, the simplest solution would have been to call Laravel Http directly from a job or service.

That would work technically, but the application flow would then understand the external IndexNow protocol: endpoint, host, verification key, key location, JSON payload, and HTTP failure behavior.

The application layer actually needs a much smaller operation: submit these URLs for indexing.

That is why the external HTTP API sits behind a small contract - IndexNowClientInterface.

The role names matter: IndexNowClientInterface is not the adapter itself. It is the port or application contract. IndexNowHttpClient is the adapter because it translates that internal contract into the external HTTP protocol.

The Problem Is Not the HTTP Request but the Dependency Direction

PHP - Application job coupled to external HTTP details
final class SubmitIndexNowUrlsJob{    public function handle(): void    {        Http::post(            config('indexnow.endpoint'),            [                'host' =>                    parse_url(                        config('app.url'),                        PHP_URL_HOST,                    ),                 'key' =>                    config('indexnow.key'), 

In this version the job owns both application orchestration and infrastructure protocol details.

If the endpoint, payload, authentication, or even HTTP client library changes, application code changes too.

A job test would also need to fake the HTTP transport even though the real application question is much simpler: were the correct URLs passed to the client?

The Application Contract Describes Intent, Not HTTP

PHP - Application-facing IndexNow port
interface IndexNowClientInterface{    /**     * @param array<int, string> $urls     */    public function submit(        array $urls,    ): void;}

The contract contains no Http, endpoint, headers, JSON, or API key.

The caller passes an array of URLs. That is all the information the application layer needs for this operation.

The interface stabilizes the internal language of the system even when the external protocol is more complicated.

The Queue Job Depends on the Port

PHP - Application flow uses only the client contract
public function handle(    IndexNowUrlService $indexNowUrlService,    IndexNowClientInterface $client,): void {    if (        ! (bool) config(            'indexnow.enabled'        )    ) {        return;    }     $post = Post::query()        ->find(            $this->postId        );

The job answers application questions: is the feature enabled, does the entity still exist, is it public, and which URLs should be notified?

After that the boundary is explicit: $client->submit($urls).

The job does not know how those URLs become an HTTP payload or where the request is sent.

IndexNowHttpClient Adapts the Internal Contract to the External Protocol

PHP - Infrastructure adapter translates URLs into IndexNow HTTP
final readonly class IndexNowHttpClient    implements IndexNowClientInterface{    public function submit(        array $urls,    ): void {        $urls = array_filter(            $urls,            static fn (                mixed $url            ): bool =>                is_string($url)                && $url !== '',        )            |> array_unique(...)            |> array_values(...);

This is where protocol-specific details belong.

The adapter cleans the input, removes duplicate URLs, restores sequential indexes, and builds the IndexNow batch payload with host, key, keyLocation, and urlList.

The Laravel HTTP client also remains an infrastructure detail.

An Adapter Is a Translator Between Two Contracts

Internal Side

External Side

submit(array $urls)

HTTP POST request

Public URLs

urlList

No API key in the caller

key from configuration

No host in the caller

host from the application URL

No transport in the caller

Laravel HTTP client

An Adapter is useful not because HTTP is complicated, but because an external contract should not become the internal contract of the application.

The Laravel Container Connects the Port and Adapter

PHP - Bind the application port to the infrastructure adapter
$this->app->bind(    IndexNowClientInterface::class,    IndexNowHttpClient::class,);

Application code type-hints the interface. The Laravel container provides the production implementation.

The dependency direction therefore points inward: the application defines the contract it needs, and infrastructure implements it.

This also demonstrates Dependency Inversion, but Adapter and DIP are not the same thing. DIP defines dependency direction while the Adapter performs translation between contracts.

Application Tests Do Not Need the Real HTTP Adapter

PHP - Replace the adapter with a test implementation
$client = new class ()    implements IndexNowClientInterface{    /**     * @var array<int, string>     */    public array $submittedUrls = [];     public function submit(        array $urls,    ): void {        $this->submittedUrls =            $urls;    }}; 

An application test does not need to verify JSON, endpoints, or Laravel Http::fake().

I can inject a tiny implementation of the interface and assert which URLs reached the client.

The test therefore verifies application behavior instead of accidentally duplicating the infrastructure test.

The HTTP Adapter Is Tested Separately as an Infrastructure Boundary

PHP - Test the infrastructure adapter at its own boundary
it(    'binds indexnow client contract '    .'to http implementation',    function (): void {        expect(            app(                IndexNowClientInterface::class            )        )->toBeInstanceOf(            IndexNowHttpClient::class        );    }); it(    'does not send request '

A dedicated infrastructure test is allowed to know about IndexNowHttpClient, the Laravel HTTP facade, and the external payload.

In ICanUp, this test also locks the container binding and the rule that an empty URL list produces no HTTP request.

The result is two test levels with different reasons to fail.

The Adapter Knows Nothing About Publication Rules

PHP - Publication policy remains outside the adapter
if (    $post->published_at === null    || $post->published_at        ->getTimestamp()        !== $this            ->expectedPublishedAtTimestamp) {    return;} if (    ! $post->isPubliclyVisible()) {    return;} 

IndexNowHttpClient does not decide whether a Post is Draft, Scheduled, or Published. It also does not build canonical URLs.

Those decisions belong on the application/domain side. The job verifies the current publication state, IndexNowUrlService builds targets, and only then does the adapter receive the ready URLs.

This keeps the infrastructure adapter narrow: transport translation rather than business policy.

External Failure Also Stays Behind the Boundary

An HTTP request can fail because of the network, a timeout, or an error response. That is infrastructure reality, but application code should not spread HTTP-specific handling across every caller.

Queued jobs can own their retry policy while the adapter owns the concrete HTTP interaction.

The boundaries stay clear: the job manages use-case execution and the adapter manages external transport.

What Happens if the Integration Changes

Change

Where It Should Stay

A different HTTP endpoint

IndexNowHttpClient / configuration.

A different IndexNow payload

IndexNowHttpClient.

A different Laravel/PHP HTTP transport

Infrastructure adapter.

An additional API key mechanism

Infrastructure adapter/configuration.

Another provider with the same internal intent

A new IndexNowClientInterface implementation.

New publication rules

Domain/application layer, not the adapter.

Adapter Is Not the Same as Strategy

Strategy

Adapter

Choose between multiple algorithms.

Translate one contract into another.

Implementations share one business role.

The implementation sits at an integration boundary.

SearchProvider executes a search algorithm.

IndexNowHttpClient translates submit URLs into an HTTP protocol.

Variation belongs to application behavior.

Variation comes from external infrastructure.

Adapter Is Not the Same as Factory

In the previous article, ThreadResolverFactory decided which resolver to return to the caller.

Here IndexNowHttpClient selects nothing. Its job is to make an external API compatible with the internal IndexNowClientInterface.

A Factory answers which object should be used. An Adapter answers how incompatible contracts should be connected.

Adapter and Dependency Inversion Work Together

This example can also be confused with the Dependency Inversion Principle.

DIP: application code depends on IndexNowClientInterface rather than a concrete HTTP implementation.

Adapter: IndexNowHttpClient implements that internal contract and translates it into the IndexNow HTTP protocol.

One architectural design can demonstrate several principles and patterns at the same time, but their roles are still worth distinguishing.

When I Would Use This Kind of Adapter

  • An external API has its own transport or payload contract.
  • The application needs a simpler domain-oriented operation.
  • The integration can change independently from the use case.
  • Application tests should not depend on HTTP.
  • Credentials and endpoints should not leak into business/application services.
  • Provider-specific failure should remain separate from domain decisions.

When an Adapter Would Be Overkill

Not every HTTP request needs a dedicated interface and adapter class.

If an integration is one-off, has no application meaning, does not need isolated testing, and is unlikely to change, another abstraction can simply make the code harder to follow.

The IndexNow boundary is justified because submission is used by queued jobs, publication flows, and tests while the external protocol has its own configuration and payload rules.

A good Adapter does not hide the integration. It hides the integration-specific dialect from code that should not need to speak it.

Conclusion

In ICanUp, the application layer does not send an IndexNow HTTP request. It asks IndexNowClientInterface to perform submit($urls).

IndexNowHttpClient sits at the infrastructure boundary and adapts that simple internal operation to the external protocol: it normalizes the URL list, builds the payload, and performs the HTTP request.

The job keeps publication logic. The URL service keeps target-building rules. The adapter keeps HTTP integration details.

This is how Adapter Pattern helps me prevent an external API from defining the shape of internal application code.