Eleven chapters in, you have an API that validates at the boundary, responds consistently, authenticates properly, and fails loudly instead of quietly. That's the foundation. Now people start asking for more: "can you tell us when a license changes?", "can we upload 500 domains at once?", "can we search across everything?". Each question sounds simple. Each one, built carelessly, becomes the thing that pages you at 2 AM.
This chapter covers four features teams reach for once the basics feel boring: webhooks, batch operations, search, and long-running exports. It does not just sketch them. It shows what each one costs, where it breaks under real load, and the specific Laravel mechanism that keeps it from breaking.
Advanced features aren't optional extras bolted onto a working API, they're the same discipline you already applied to CRUD, applied to harder problems. Cut corners here and you cut them exactly where consumers can't see what went wrong.
Add These When the Basics Are Boring
Here's the rule: don't build any of this until the plain version has already worked, in production, for a while. A webhook system for an API with three consumers is a solution looking for a problem. A search index for a table with 200 rows is the same mistake. These features earn their complexity budget only when the simple thing, polling, filtering client-side, running a report by hand, has visibly started costing more than it saves.
Once you're there, half-measures are worse than not building the feature at all. A webhook that silently drops on failure is worse than no webhook, because consumers build on the assumption that it's reliable. A batch endpoint that times out at 200 records teaches your biggest customer to stop trusting it. Build these right, or don't build them.
Webhooks: Delivery You Can Prove
A webhook is a promise: when something happens on your side, you'll tell the other side, without them asking. The promise is easy to make and easy to break. The value of a webhook system isn't the HTTP POST, it's what happens when that POST fails.
Say a partner integration wants to know the moment a license is issued. LicenseFacade::addLicense() only hands back the raw array Statamic returned, so LicenseController::create() already runs that array through LicenseDTOMapper::toDTO() before anything downstream touches a LicenseDTO. Hook the webhook into that same seam: once the mapper produces a LicenseDTO, fire a LicenseIssuedEvent carrying it, the subscriber's webhook URL, and a secret for signing (more on that shortly). The listener that actually delivers the webhook is the part worth getting right. It has to be queued, because a subscriber's endpoint being slow can't slow down license creation. It has to retry, because a subscriber's endpoint being briefly down isn't your problem to propagate. And it has to tell a 4xx apart from a 5xx, because those mean opposite things.
// app/Listeners/DeliverLicenseWebhookListener.php
final class DeliverLicenseWebhookListener implements ShouldBeEncrypted, ShouldQueue
{
use InteractsWithQueue;
public int $tries = 4;
/** @return array<int, int> */
public function backoff(): array
{
return [5, 15, 30];
}
public function handle(LicenseIssuedEvent $event): void
{
if (! $event->webhook) {
return;
}
$response = Http::connectTimeout(5)
->timeout(15)
->retry(2, 250)
->acceptJson()
->asJson()
->post($event->webhook, [
'event' => 'license.issued',
'data' => [
'key' => $event->license->key,
'name' => $event->license->name,
'domains' => $event->license->domains,
],
]);
// A 4xx means the URL or its auth is wrong, and it will still be
// wrong on attempt two. A 5xx or a dropped connection is transient
// and worth the retry.
if ($response->clientError()) {
return;
}
$response->throw();
}
}
ShouldBeEncrypted guards the payload and the target URL while they sit in the queue, since a license key and a subscriber's endpoint aren't things you want in plaintext in a queue table. tries and backoff() give a failing subscriber four attempts spread over roughly a minute before Laravel calls failed(). clientError() vs throw() is the whole point: a 404 or a 401 is terminal, retrying it just wastes a worker slot, while a 500 or a dropped connection throws, and the queue's retry mechanism picks that up automatically.
license.issued fires
↓
listener queued
↓
Http::post to subscriber
↓
2xx done · 4xx terminal · 5xx retried up to 4 times
"Delivery you can prove" means prove it, don't assert it. A log line inside handle() tells you it fired, not whether the subscriber got it, how many attempts it took, or which webhook has been silently failing for three days. A webhook_deliveries table with event, webhook, response_status, attempt, and delivered_at columns fixes that: write a row before the request, update it after. Now "did the webhook fire" is a query, and "which subscribers are failing right now" is a dashboard instead of a support ticket that opens with "I think we stopped getting updates."