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 Problem Is Not the HTTP Request but the Dependency Direction
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
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
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
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 |
|---|---|
| HTTP POST request |
Public URLs |
|
No API key in the caller |
|
No host in the caller |
|
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
$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
$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
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
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 |
|
A different IndexNow payload |
|
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 |
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.



