Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ composer require utopia-php/cdn

## Cache Purging

The cache API supports three purge modes:
The cache API supports four purge modes:

- path purges scoped to a domain
- domain-wide purges
- key or cache-tag purges for providers like Fastly and Cloudflare
- zone-wide purges, for when the whole cache has to go

Domains are lowercase hostnames without a scheme or trailing slash. Paths begin with `/`; CDN resources are assumed to use HTTPS.

Expand Down Expand Up @@ -51,6 +52,12 @@ $cache->purgeKeys([
]);
```

Cloudflare purges a hostname natively, so `purgeDomain()` evicts everything served for that hostname and nothing served for another. Every purge method is [available on all plans](https://developers.cloudflare.com/changelog/post/2025-04-01-purge-for-all/). `purgeKeys()` purges cache tags, which only match responses the origin tagged with a `Cache-Tag` header.

URLs and tags are batched at `PATHS_PER_PURGE` and `KEYS_PER_PURGE` per request, both 30. Cloudflare documents higher ceilings — 100 URLs per request, 500 on Enterprise, and 100 operations for tags — so 30 is a conservative default rather than a limit, and unchanged from what this adapter has always sent.

`purgeZone()` purges every cached response in the zone (`purge_everything`).

### Fastly

```php
Expand All @@ -61,6 +68,7 @@ use Utopia\Cdn\Cache\Adapter\Fastly;

$cache = new Cache(new Fastly(
apiToken: 'YOUR_API_TOKEN',
domainKeyPrefix: 'domain-',
serviceId: 'YOUR_SERVICE_ID',
softPurge: false
));
Expand All @@ -70,13 +78,26 @@ $cache->purgePaths('example.com', [
'/files/logo.svg',
]);

// Purges the surrogate key "domain-example.com".
$cache->purgeDomain('example.com');

$cache->purgeKeys([
'host-deadbeef',
'deployment-12345',
]);
```

Fastly domain purges invalidate the entire configured service. Use one domain per Fastly service when calling `purgeDomain()`. Cloudflare hostname purging depends on the cache-purge features enabled for your plan.
`domainKeyPrefix` is required because [Fastly has no purge-by-host operation](https://www.fastly.com/documentation/reference/api/purging/) — its purge API offers URL, surrogate key and whole-service purges and nothing in between. A domain is addressed by the surrogate key the origin attaches to every response it serves for that domain, so the adapter has to know how those keys are named. Pass `''` when the key is the bare hostname.

Keys are sent as given, in the request body, batched up to 256 per request. A Fastly adapter with no service ID can still purge paths; key, domain and zone purges raise `Exception\UnsupportedOperation`.

`purgeZone()` purges everything on the service (`purge_all`), which Fastly documents as taking up to two minutes, being incompatible with soft purge, and likely to spike origin traffic on a busy service. Prefer `purgeDomain()` or `purgeKeys()`.

### The adapter contract

`Cache\Adapter` declares the four purges every adapter offers — `purgePaths()`, `purgeDomain()`, `purgeKeys()` and `purgeZone()` — so a caller never has to know which provider is behind it. Providers differ in what they expose natively, and the adapter absorbs the difference: Cloudflare purges a hostname directly, while Fastly maps the same call onto a surrogate key. Where an adapter cannot serve an operation with the configuration it was given it raises `Exception\UnsupportedOperation`, rather than quietly doing something wider.

Adapters name things alike, with provider-specific numbers behind the same names: `KEYS_PER_PURGE` is how many cache keys one request may carry, 256 on Fastly and 30 on Cloudflare. Only Cloudflare declares `PATHS_PER_PURGE`, because a Fastly URL purge takes one URL and has nothing to batch.

## Certificates

Expand Down
8 changes: 8 additions & 0 deletions src/Cdn/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,12 @@ public function purgeKeys(array $keys): void
{
$this->adapter->purgeKeys($keys);
}

/**
* Purges the adapter's entire cache. Prefer purgeDomain() or purgeKeys().
*/
public function purgeZone(): void
{
$this->adapter->purgeZone();
}
}
19 changes: 19 additions & 0 deletions src/Cdn/Cache/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

namespace Utopia\Cdn\Cache;

/**
* The purge operations every provider adapter offers.
*
* Providers differ in what they expose natively — Cloudflare purges a hostname directly, Fastly has
* to be told which surrogate key stands for one — but a caller should not have to know which. Every
* operation here is therefore implemented by every adapter, or the adapter raises
* Exception\UnsupportedOperation to say it cannot serve this one with the configuration it was given.
*
* Naming follows the object being purged: paths, a domain, cache keys, the whole zone.
*/
interface Adapter
{
/**
Expand All @@ -15,4 +25,13 @@ public function purgeDomain(string $domain): void;
* @param array<int, string> $keys
*/
public function purgeKeys(array $keys): void;

/**
* Purges everything the adapter is configured for, whatever domain it belongs to.
*
* The widest operation a provider offers: Cloudflare's purge_everything for the zone, Fastly's
* purge_all for the service. Everything cached is then re-fetched from origin, including all the
* content that did not need to be, so reach for purgeDomain() or purgeKeys() first.
*/
public function purgeZone(): void;
}
Comment thread
Meldiron marked this conversation as resolved.
93 changes: 54 additions & 39 deletions src/Cdn/Cache/Adapter/Cloudflare.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,26 @@

class Cloudflare implements Adapter
{
/**
* URLs per purge request. Names the batch size this adapter has always used rather than
* changing it: Cloudflare documents 100 URLs per request, 500 on Enterprise, so 30 is within
* every plan and can be raised deliberately.
*/
public const int PATHS_PER_PURGE = 30;

/**
* Cache tags per purge request. Cloudflare documents 100 operations per request for tags on
* every plan; 30 is what this adapter has always sent.
*/
public const int KEYS_PER_PURGE = 30;

private ClientInterface $client;

public function __construct(
private string $zoneId,
private string $apiToken,
?ClientInterface $client = null,
private string $apiBase = 'https://api.cloudflare.com/client/v4'
private string $apiBase = 'https://api.cloudflare.com/client/v4',
) {
$this->client = $client ?? new Client(new CurlAdapter());
}
Expand All @@ -35,34 +48,55 @@ public function purgePaths(string $domain, array $paths): void
return;
}

foreach (\array_chunk($paths, 30) as $chunk) {
$urls = \array_map(fn (string $path): string => 'https://' . $domain . $path, $chunk);
$result = $this->request(
method: Method::POST,
url: '/zones/' . $this->zoneId . '/purge_cache',
body: ['files' => $urls],
);

if (!$this->isSuccess($result)) {
throw new \RuntimeException($this->formatError('Cloudflare', $result));
}
foreach (\array_chunk($paths, self::PATHS_PER_PURGE) as $chunk) {
$urls = \array_map(static fn (string $path): string => 'https://' . $domain . $path, $chunk);
$this->send(['files' => $urls]);
}
}

/**
* Purges every cached response served for the hostname, and nothing served for another.
*/
public function purgeDomain(string $domain): void
{
$result = $this->request(
method: Method::POST,
url: '/zones/' . $this->zoneId . '/purge_cache',
body: ['hosts' => [Domain::validate($domain)]],
);
$this->send(['hosts' => [Domain::validate($domain)]]);
}

public function purgeKeys(array $keys): void
{
if ($keys === []) {
return;
}

// Cache tags only match responses the origin tagged with a Cache-Tag header.
foreach (\array_chunk($keys, self::KEYS_PER_PURGE) as $chunk) {
$this->send(['tags' => $chunk]);
}
}

/**
* Purges every cached response in the zone, whatever hostname it was served for.
*/
public function purgeZone(): void
{
$this->send(['purge_everything' => true]);
}

/**
* @param array<string, mixed> $body
*/
private function send(array $body): void
{
$result = $this->request(Method::POST, '/zones/' . $this->zoneId . '/purge_cache', $body);

if (!$this->isSuccess($result)) {
throw new \RuntimeException($this->formatError('Cloudflare', $result));
throw new \RuntimeException($this->formatError($result));
}
}

/**
* A 2xx is not enough: Cloudflare reports a rejected purge in the body.
*
* @param array{statusCode:int,response:array<string, mixed>|string|null,error:string|null} $result
*/
private function isSuccess(array $result): bool
Expand All @@ -73,29 +107,10 @@ private function isSuccess(array $result): bool
&& ($result['response']['success'] ?? false) === true;
}

public function purgeKeys(array $keys): void
{
if ($keys === []) {
return;
}

foreach (\array_chunk($keys, 30) as $chunk) {
$result = $this->request(
method: Method::POST,
url: '/zones/' . $this->zoneId . '/purge_cache',
body: ['tags' => $chunk],
);

if (!$this->isSuccess($result)) {
throw new \RuntimeException($this->formatError('Cloudflare', $result));
}
}
}

/**
* @param array{statusCode:int,response:array<string, mixed>|string|null,error:string|null} $result
*/
private function formatError(string $provider, array $result): string
private function formatError(array $result): string
{
$message = $result['error'] ?? null;

Expand All @@ -105,7 +120,7 @@ private function formatError(string $provider, array $result): string

$message ??= 'Unknown purge error';

return $provider . ' purge failed with status ' . $result['statusCode'] . ': ' . $message;
return 'Cloudflare purge failed with status ' . $result['statusCode'] . ': ' . $message;
}

/**
Expand Down
Loading
Loading