qoliber / trident-php
PHP client library for Trident HTTP Cache Proxy
Requires
- php: ^8.1
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/http-server-middleware: ^1.0
- psr/log: ^2.0 || ^3.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.0
- guzzlehttp/psr7: ^2.0
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^10.0 || ^11.0
- qoliber/servermock: ^1.0
- squizlabs/php_codesniffer: ^3.7
Suggests
- ext-sodium: for Security\TokenVault: the admin-stored API token sealed to its URL
- guzzlehttp/guzzle: ^7.5 — for Http\TransportFactory: libcurl, no redirects, the NetworkGuard pinned per request
Provides
None
Conflicts
None
Replaces
None
README
Official PHP client library for Trident HTTP Cache Proxy.
Requirements
- PHP 8.1 or higher
- PSR-7/PSR-17/PSR-18 compatible HTTP client (e.g., Guzzle)
Installation
composer require qoliber/trident-php
Quick Start
use Qoliber\Trident\TridentFactory; // Create from environment variables $trident = TridentFactory::createFromEnv(); // Or create with explicit configuration $trident = TridentFactory::create( adminUrl: 'http://localhost:9100', apiKey: 'your-admin-api-key' ); // Check health if ($trident->isHealthy()) { echo "Trident is running!"; } // Get cache stats $stats = $trident->stats(); echo "Hit ratio: {$stats->getHitRatioPercent()}%";
Cache Purging
Purge by Tag
// Single tag $trident->purgeTag('product.123'); // Multiple tags (OR - purge if ANY tag matches) $trident->purgeTags(['product.1', 'product.2', 'product.3']); // Multiple tags (AND - purge if ALL tags match) $trident->purgeTagsAll(['category.electronics', 'featured']); // Pattern matching (wildcard) $trident->purgeTagPattern('product.*'); // Pattern matching (regex) $trident->purgeTagPattern('product\.\d+', 'regex');
Purge by URL
// Hard purge (remove immediately) $trident->purgeUrl('https://example.com/product/123'); // Soft purge (mark stale, serve while revalidating) $trident->purgeUrl('https://example.com/product/123', soft: true);
Purge All
// Clear entire cache $trident->purgeAll();
Convenience Methods
// E-commerce shortcuts $trident->purgeProduct(123); $trident->purgeCategory(45); $trident->purgePage('home'); // Bulk purge patterns $trident->purgeAllProducts(); // Purges product.* $trident->purgeAllCategories(); // Purges category.*
Cache Tags
Tag Collection
use Qoliber\Trident\Cache\TagCollection; // Build a collection of tags $tags = TagCollection::create() ->addProduct(123) ->addCategory(45) ->add('store.default') ->add('featured'); // Use in response headers header('X-Cache-Tags: ' . $tags->toHeader()); // Or purge the collection $trident->purgeCollection($tags);
Tag Resolver
use Qoliber\Trident\Cache\TagResolver; $resolver = new TagResolver(); // Register custom resolver for your entities $resolver->register('product', function ($product) { return [ "product.{$product->getId()}", "category.{$product->getCategoryId()}", "store.{$product->getStoreId()}", ]; }); // Resolve tags from entities $tags = $resolver->resolve('product', $product);
Durable invalidation for platform modules (1.3.0)
Platform-neutral building blocks shared by the platform integrations (WooCommerce today; Magento, Shopware, Sylius and PrestaShop next), so the delivery contract is written — and tested — once.
| namespace | what |
|---|---|
Qoliber\Trident\Delivery |
X02/X03 delivery: Purger (record now with a grace period other drainers respect, deliver the process's own rows at the end of the request, remove only when acknowledged — safe for platforms that save without a transaction), Drainer, Packer (≤ 1000 tags per request), Backoff (1 s … 300 s, never gives up), Acknowledgement (HTTP 200 + int purged + string mode, optional state ≠ refused; or 202 + state: recorded in reflect mode), Instances/Instance (several Trident servers, each delivered and retried on its own), PurgeClient over a Transport, Psr18Transport |
Qoliber\Trident\Tags |
TagSet: bounded (Trident's 200-tag default, a header byte budget), prioritised (identity > reference > listed), normalised tags with an overflow tag |
Qoliber\Trident\Cache |
Policy / Decision / RequestContext: generic full-page cacheability rules; the platform passes its lists (state-changing query parameters, logged-in and session cookie prefixes) |
Qoliber\Trident\Esi |
Markup::includeWithFallback() (<!--esi <esi:include/> --><esi:remove>inline</esi:remove>, src validated), FragmentUrl (signed fragment URLs), FragmentResponse::shared()/::private() (headers: own TTL and tags / never stored), TridentOrigin::matches() (did the request come from a Trident address) |
Qoliber\Trident\Testing |
InMemoryOutboxStore, FakeTransport — test doubles other packages can reuse (they are in src/ because Composer never loads a dependency's autoload-dev) |
assets/js/trident-sections.js |
the platform-neutral half of "private content from a local cache" (placeholders filled from localStorage under a version cookie); a platform binding configures it and serves the data endpoint |
A platform provides two adapters and wires them:
use Qoliber\Trident\Delivery\{Instances, Purger, PurgeClient, Psr18Transport}; // 1. OutboxStore on the platform's own database layer, written through the SAME // connection the platform saves entities with. `record()` takes a due time // (the writer's grace) and returns the row id; `byIds()` returns rows by id. // Binary collation on `instance`. See WpdbOutboxStore in // integrations/ecommerce/woocommerce for a complete implementation. $store = new MyDoctrineOutboxStore($connection); // 2. Transport: Psr18Transport for Composer-native platforms, or your own // (WordPress HTTP API, Magento Curl). $transport = new Psr18Transport($psr18Client, $requestFactory, $streamFactory); [$instances, $errors] = Instances::parse($deploymentConfigList, $adminUrl, $adminToken); $purger = new Purger( $store, $instances, fn ($i) => new PurgeClient($i, $transport), 'soft', fn (callable $deliver) => $eventDispatcher->addListener('kernel.terminate', $deliver), // end of request ); $purger->purgeTags(['product_42', 'category_7']); // on save $purger->drain(500); // from cron / a CLI command $purger->drain(500, ignoreBackoff: true); // an operator's "deliver now"
TridentClient purge methods now also say whether Trident acknowledged the
purge: PurgeResponse::isAcknowledged(), ->state, ->failure. The old
isSuccess() is unchanged (true for any purge body) and must not be used to
decide that a purge happened.
Admin screens for platform modules (1.4.0)
Everything a platform's admin needs to show and drive Trident — the screen set of the Magento module (dashboard, purge, cached pages, tags, coverage, warmer, launch, reflect, denoisers, bans, backends, discovery, live events) — with no per-platform API code.
| class | what |
|---|---|
Qoliber\Trident\Admin\Api |
the ONE request path to an instance's admin API: bearer token (only when set), JSON in and out, an empty body sent as {} (the engine rejects []), errors mapped to ApiError, and one bounded retry when the admin rate limiter answers 429 with retry_after_ms (capped at 1 s). TridentClient and PurgeClient both use it |
Qoliber\Trident\Admin\ApiError |
status (0 = no response), the engine's error code (REFLECT_DISABLED, LAUNCH_ACTIVE, …), isUnreachable(), isFeatureDisabled() (a 404 *_DISABLED or a 503 "… not enabled": show it as information, not a failure), reason() (never the token) |
Qoliber\Trident\Admin\Fleet |
every configured instance (Instances::parse()): each() / on($names) run a call per instance and return one InstanceResult each — a dead instance is reported in its result, never thrown, so a screen still shows the live ones |
Qoliber\Trident\Admin\Payload |
a never-throwing, dotted-path view of wide or growing responses (warmer, reflect, denoisers, coverage, explain, variants) |
Qoliber\Trident\Admin\SiteUrl |
a storefront URL split the way the engine keys entries (path + host + scheme): the engine does not parse an absolute URL in url, and defaults the scheme to https |
use Qoliber\Trident\Admin\Fleet; use Qoliber\Trident\Client\TridentClient; $fleet = new Fleet($instances, $transport); // the same instances and transport the purges use foreach ($fleet->each(fn (TridentClient $c) => $c->stats()) as $result) { echo $result->isOk() ? sprintf("%s: %.1f%% hits\n", $result->name(), $result->value->getHitRatioPercent()) : sprintf("%s: %s\n", $result->name(), $result->reason()); } $fleet->on(['edge-1'], fn (TridentClient $c) => $c->purgeUrl('https://shop.example/p/beanie/'));
TridentClient::forInstance($instance, $transport) builds a client over any
Transport (the WordPress HTTP API, Magento's Curl); new operator calls:
status(), purgeHost(), purgeVary(), coverage(), cacheVariants(),
explain(), warmerStatus|Run|Cancel|Queue(), launch(),
reflectStatus|Enable|Disable|Queue(), denoiserReport(),
denoiserPathZones(), denoiserQueryScopes(), denoiserQuery|PathPin(),
…Unpin(), denoiserReset(), esiFragments(), and
EventStream::parseChunk() for a bounded read of an SSE stream.
Corrected against the engine in the same release (each was checked on a live
1.8.0 Trident): the launch calls no longer append a launch id the engine does
not have; memoryStats() reads GET /admin/memory; snapshot() and the
discovery calls use the engine's paths; purgePreview() sends
url_pattern/tag/tags/tag_pattern and reads would_free_bytes and the
sample; cacheEntries()/cacheTags() gained the engine's sort/tag/prefix
filters; CacheStatsResponse now carries hits, misses, passes and the hit
ratio (it always reported 0 %); LaunchResponse reads the engine's success;
list and entry responses read size, content_type, vary, and discovery
reads backend_name/addresses. purgeUrl() splits an absolute URL into
path, host and scheme.
1.4.1 — fixes
- Anything that is not the admin API is an error. Every engine admin
endpoint answers JSON.
Admin\Api::call()used to turn a redirect (anhttp://API URL in front of an https-only listener, a wrong path) or a proxy's HTML page intosuccess: true, so a screen could report "Launch started" or an empty "reachable" dashboard when Trident never saw the request. A 3xx, or a 2xx whose body is not JSON, now throwsApiError;204 No Contentis still a success. Durable purge delivery (Delivery\PurgeClient, judged byAcknowledgement) is unchanged. purgeTagPattern()sendspattern_type, the field the engine reads. It senttype, which the engine ignored, so a regex tag pattern was purged as a wildcard.
1.5.0 — a complete admin client
Everything a platform's admin screens need is now on the typed client; the
lower-level Admin\Api is for what the library does not cover yet. Nothing is
removed or renamed: 1.4.x code runs unchanged (the 1.4.1 test suite passes
as-is).
- Every typed response keeps the engine's full answer.
raw()returns the decoded JSON it was built from,payload()the same answer as a dot-pathPayload($stats->payload()->int('hits')).Payload::raw()is the same for the untyped reads.toArray()keeps its 1.4 shape — useraw()for the complete answer. - Fields the engine sends are no longer dropped or read under the wrong
name (checked against a live 1.8.0 engine; the old names stay as
fallbacks): health uptime (
uptime_seconds), compressed entries (current_compressed_entries),compressionsTotal,tagIndexedKeys,urlIndexedKeys, protectionenabled(protection_enabled) with thetotal*counters and per-backend queue figures (backendsstays keyed by backend name — the 1.4.1 shape — and each row carries itsname), memoryfootprintPercentiles, launchactive, connectionqueued(waitingRequests),totalCreated,totalReused, latencysampleCount. purge(PurgeRequest)sends a purge with every option the engine has: soft/hard on every kind (tag, tags and tag pattern included),exclude_tagsand the match mode for tags,pattern_typefor tag patterns. The mode is sent only when you choose one (->soft()or->hard()); a request without either leaves it to the operator'sadmin.default_purge_mode, whose engine default is soft.getMode()still answershardfor such a request (its 1.4.1 answer) —hasExplicitMode()says whether a mode is sent.purgeUrl()keeps sending its explicit mode, as in 1.4.1.PurgeRequestgainedhard(),excluding(),hasExplicitMode(),engineEndpoint()andtoEngineBody().clearCache(): ClearResponse— a full clear typed on the engine's clear schema (cleared,entriesRemoved,bytesFreed,isAcknowledged()). A clear has no soft/hard mode.purgeAll()is unchanged.- Delivery reports what was purged.
PurgeAttemptcarriespurgedandstate(a deferred 202 purge has purged nothing yet:purgedis null);DrainReport::$purgedand each instance'spurgedadd them up.PurgeClient::clear()clears an instance, judged by the clear schema. explainRequest($url, $method, $headers, $cookies, $detail)— the full request context/admin/explainaccepts; cookies go in theCookieheader, the URL's host becomesHostunless given. A cookie name or value that would inject another cookie or header (;,,, control characters; a name with=or whitespace) is refused, not rewritten. The scheme is not sent: the engine evaluates explain without TLS context, so for an https page onlycacheableandreasonare meaningful.Exception\InvalidRequest(aTridentException) — a request the client refuses to send: an invalid denoiser pin, an injecting cookie. Code that catchesTridentExceptionfor admin failures, as 1.4.x code does, still catches it; nothing reaches Trident.- Denoisers: pins are validated before anything is sent (
classnoise|signal,statusdead|alive, a non-empty parameter);denoiserPathZoneDelete(),denoiserQueryScopeDelete()andwafExport()(thetrident-waf-v1export) are new.
TridentClientInterface is frozen for 1.x. The new methods (purge(),
clearCache(), explainRequest(), wafExport(), the denoiser deletes) are on
the TridentClient class only: adding methods to the interface would break
every class that implements it. Type against TridentClient to use them.
Which layer to use (platform authors)
| you need | use |
|---|---|
| purges that must not be lost (a product save) | Delivery\Purger + your OutboxStore — recorded with the change, delivered after commit, retried |
| one instance, one admin call | Client\TridentClient (typed; raw()/payload() for any field) |
| the same call on every configured instance | Admin\Fleet — one InstanceResult per instance, a dead one reported, never thrown |
| a purge with every engine option | TridentClient::purge(PurgeRequest …) |
| an endpoint the client has no method for yet | Admin\Api::call() — the one request path the client itself uses |
1.8.1 — which Trident this is for
Packages are versioned in lockstep with the engine: MAJOR.MINOR is Trident's
(library 1.8.x works with Trident 1.8), PATCH is the library's own.
Qoliber\Trident\Compatibility::TRIDENT names the release line ('1.8'), and
Compatibility::warning($engineVersion) returns a message for an admin screen
when a connected engine runs another MAJOR.MINOR (null when it matches or is
unreadable). The shared Admin\AdminService dashboard shows it as its
"compatibility" row, so every platform built on it gets the warning.
1.8.0 (unreleased) — one delivery, settings and tag policy for every platform
Five public classes that were platform code before: Delivery\OutboxDelivery
(record a purge per instance, flush this request's rows, drain, drainAll(),
status, and a direct send when the outbox cannot be written),
Config\Settings + Config\SettingsResolver (the deployment configuration
wins over the admin setting; an environment token goes only to environment
URLs; the host allowlist; purge mode, tag prefix, debug headers) and
Tags\TagPolicy (the same tag names in the header and in the purge: prefix,
200 tags / 4 KiB, per-kind overflow tags). Used by trident-symfony and the
PrestaShop module. A blank tag is now ignored instead of becoming the bare
prefix. Integrations using them require ^1.8.
1.7.0 (unreleased) — the security pieces every platform needs
Moved out of the Shopware plugin, so the Sylius bundle (and every later platform) uses one implementation instead of a copy:
Http\NetworkGuard— addresses a Trident API URL may never reach: link-local ranges (169.254.0.0/16,fe80::/10), the unspecified addresses (0.0.0.0/8,::), every IPv6 form that carries an IPv4 address (v4-mapped, v4-compatible, NAT6464:ff9b::/96, 6to42002::/16), numeric host spellings (2852039166, octal, hex), and the cloud metadata services (fd00:ec2::254,100.100.100.200,metadata.google.internal). Checked on the RESOLVED address. Private ranges stay allowed: Trident normally is internal.Http\TransportFactory— the PSR-18 client for Trident's admin API: libcurl, no redirects (the token must not follow one), and no proxy:CURLOPT_NOPROXY=*, so neither Guzzle nor libcurl uses an environment proxy (http_proxy,HTTPS_PROXY,ALL_PROXY) for admin traffic — a proxy would resolve the target itself (defeating the pin and the guard) and, onhttp://, see the bearer token. TheNetworkGuard's checked address is pinned for the connection (CURLOPT_RESOLVE), so DNS rebinding cannot swap it. A deployment that must use a proxy passes it explicitly ($proxy,http(s)://host[:port]); pinning is then off (the guard still checks the name). A custom Guzzle handler is refused unless it declares that it honours the curl options. Needsguzzlehttp/guzzle^7.5 (suggested).Security\TokenVault— an admin-stored API token encrypted at rest and bound to its URL: XChaCha20-Poly1305 with the normalised URL (scheme, host, port, path) as associated data; key = HKDF-SHA256 of the application secret with a per-platform$infolabel. Pointing the URL elsewhere makes the token unusable until it is re-entered. Needs ext-sodium.Delivery\ApiHostAllowlist— the optional allowlist of API hosts (hostorhost:port, exact matches; case, a trailing dot and IPv6 brackets normalised), with a reason per dropped instance.
And, shared by the Shopware plugin and the Symfony bundle:
Admin\AdminService— the admin screens' logic (dashboard, purge, cached pages, tags, coverage, warmer, launch, reflect, denoisers, bans, backends, discovery, events) overAdmin\AdminClient. A platform provides anAdmin\AdminContextfactory (its settings, instances andPurgeOutbox— its durable delivery) and aShopAdapter(its hosts, URLs and catalogue lookups; alsoEntityInvalidatorwhen entity purges should go through the platform's own invalidation, as Shopware's do). Pattern purges and bans are scoped to the shop's own hosts: the engine matches a pattern against every host of a shared Trident.- Second delivery (
Delivery\Purger::scheduleRedelivery()) — a purge can be delivered again$redeliverAfterseconds later (Shopware and Symfony use 10 s; 0, the default, keeps the old behaviour), for a page rendered from data the purge's transaction had not yet made visible. A store that records it implementsDelivery\ScheduledStore: such rows are not owed (not inpending) until due, and an identical scheduled tag set is recorded once per instance. Delivery\Drainer::drainAll()— "deliver now": every row, whatever its due time (second deliveries, backstop rows, rows in grace or backoff). For operators after an incident and for test suites; the platforms expose it astrident:purge:drain --now.
1.6.0
Admin\WafView— the part of an instance's WAF export (wafExport()) and learned query scopes (denoiserQueryScopes()) that belongs to one shop:deadZones($export, $hosts)andnoise($scopes, $hosts)keep the shop's own host(s), plus the*rows markedinert(no*fallback in the engine: old wildcard pins that apply to nothing and can be removed with unpin). The export is global on a shared Trident; a shop's screen must not list other shops' paths. Shared by the WooCommerce plugin and the Shopware plugin (it used to live in the WooCommerce plugin only).- Denoiser pins need a real host.
denoiserQueryPin()anddenoiserPathPin()throwInvalidRequestfor host*or an empty host (including the old*default). The engine keys learned scopes and zones as{host}|{path_prefix}with the request's host and has no wildcard fallback, so such a pin was accepted and never applied. Unpin and zone/scope delete still take*, to clean such pins up. Behaviour change: a caller that pinned on*(or relied on the default) now gets an exception instead of a silently inert pin. purgeUrls()is fixed. In 1.5 it was never acknowledged and always reported 0 purged: the engine's bulk answer carriestotal_purged, notpurged. And absolute URLs purged nothing, because the bulk endpoint takes paths plus onehost/scheme. Now:- URLs are grouped by origin (paths form a group of their own), one request per group;
- each group is judged on its own (a 200 with the schema or a 202
recorded), and the result is acknowledged only when every group is; - every request failing throws, as 1.5 did;
- some failing gives
isSuccess()false andisAcknowledged()false, with the failed groups infailureandraw()['failed_groups']; - an answer that is not an acknowledgement counts as a success, as in 1.5, but not as acknowledged;
purgedCountis the sum of the groups'total_purged.
SiteUrl::parse()drops the scheme's default port (https://shop.example:443/a→ hostshop.example) and converts an IDN host to punycode, sopurgeUrl()/purgeUrls()hit the browser's key. The IDN conversion needs ext-intl; without it a non-ASCII host is kept as written.- Denoiser pin hosts are sent trimmed and lowercased, as the engine keys them.
- API URLs are
scheme://host[:port][/base-path]only (Instances::parse,Instances::isHttpUrl). A query, fragment or credentials are refused: the client appends/admin/…, and a query string would carry that path to another service. ApiErrorclips answer bodies (message andengineMessage) to 300 characters without control characters. What answered is not necessarily Trident, and its body must never land on a screen whole.- Per-kind overflow tags (
TagSet, optional): pass$overflowFamilies(regex → overflow tag) and a dropped tag of a family adds THAT family's overflow tag instead of the general one;TagSet::overflowTagsFor()gives a purge only the overflow tags of the kinds it carries. An over-tagged listing is then refreshed by a product save, not by every unrelated save. Without the argument nothing changes. coverage()takes the method the engine checks (CacheCoverageRequest.method, default GET):coverage($paths, $host, $scheme, 'HEAD').
PSR-15 Middleware
Cache Tag Middleware
use Qoliber\Trident\Middleware\CacheTagMiddleware; // Add to your middleware stack $middleware = new CacheTagMiddleware( headerName: 'X-Cache-Tags', separator: ',' ); // In your request handlers, add tags: CacheTagMiddleware::addTags($request, 'product.123', 'category.45');
Cache Control Middleware
use Qoliber\Trident\Middleware\CacheControlMiddleware; $middleware = new CacheControlMiddleware( defaultTtl: 3600, // 1 hour defaultSwr: 60, // 60 seconds stale-while-revalidate defaultPrivate: false ); // Disable caching for specific requests $request = CacheControlMiddleware::disableCache($request); // Set custom TTL $request = CacheControlMiddleware::setTtl($request, 300); // Mark as private (user-specific) $request = CacheControlMiddleware::setPrivate($request);
Environment Variables
| Variable | Description | Default |
|---|---|---|
TRIDENT_ADMIN_URL |
Trident admin API URL | http://localhost:9100 |
TRIDENT_ADMIN_KEY |
Admin API authentication key | null |
Testing
# Run unit tests composer test # Run with Docker (integration tests) cd docker docker-compose up -d docker-compose exec php-app php /var/www/html/test-api.php
Versioning
Versions follow Trident: qoliber/trident-php 1.8.x works with Trident 1.8. MAJOR.MINOR moves with the engine (every Trident X.Y.0 release is also a release of this package, changed or not); the PATCH number is this package's own. The admin screens warn when a connected Trident runs another release line.
License
MIT License - see LICENSE file for details.