CacheControlHeader.php
PHP
Path: src/Util/CacheControlHeader.php
<?php
namespace mini\Util;
/**
* Immutable Cache-Control header manipulation
*
* Inspired by PSR-7 UriInterface - all with* methods return new instances.
*
* Usage:
* ```php
* $cache = new CacheControlHeader('public, max-age=3600');
*
* // Query directives
* $cache->has('public'); // true
* $cache->get('max-age'); // '3600'
* $cache->get('private'); // null (not set)
* $cache->get('no-cache'); // true (set but no value)
*
* // Modify (returns new instance)
* $cache = $cache->with('private')->without('public');
* $cache = $cache->with('max-age', 60);
*
* // Utility methods
* $cache = $cache->withRestrictedVisibility('private'); // public -> private
* $cache = $cache->withMaxTtl(60); // cap max-age at 60
*
* // Render
* echo $cache; // "private, max-age=60"
* ```
*/
class CacheControlHeader implements \Stringable
{
/** @var array<string, true|string> Directive name => true (flag) or string (value) */
private array $directives = [];
/**
* Visibility levels from most permissive to most restrictive
*/
private const VISIBILITY_LEVELS = [
'public' => 1,
'private' => 2,
'no-cache' => 3,
'no-store' => 4,
];
/**
* Create from existing header value
*/
public function __construct(?string $header = null)
{
if ($header !== null && $header !== '') {
$this->directives = $this->parse($header);
}
}
/**
* Parse Cache-Control header string into directives
*/
private function parse(string $header): array
{
$directives = [];
foreach (explode(',', $header) as $part) {
$part = trim($part);
if ($part === '') {
continue;
}
if (str_contains($part, '=')) {
[$name, $value] = explode('=', $part, 2);
$directives[strtolower(trim($name))] = trim($value, ' "\'');
} else {
$directives[strtolower($part)] = true;
}
}
return $directives;
}
/**
* Check if a directive is set
*/
public function has(string $directive): bool
{
return isset($this->directives[strtolower($directive)]);
}
/**
* Get a directive's value
*
* @return string|true|null String value, true if flag (no value), null if not set
*/
public function get(string $directive): string|true|null
{
return $this->directives[strtolower($directive)] ?? null;
}
/**
* Get all directives
*
* @return array<string, true|string>
*/
public function all(): array
{
return $this->directives;
}
/**
* Check if header is empty (no directives)
*/
public function isEmpty(): bool
{
return empty($this->directives);
}
/**
* Add or update a directive
*
* @param string $directive Directive name
* @param string|true|null $value Value, true for flag, null to remove
*/
public function with(string $directive, string|true|null $value = true): static
{
if ($value === null) {
return $this->without($directive);
}
$new = clone $this;
$new->directives[strtolower($directive)] = $value;
return $new;
}
/**
* Remove a directive
*/
public function without(string $directive): static
{
$key = strtolower($directive);
if (!isset($this->directives[$key])) {
return $this;
}
$new = clone $this;
unset($new->directives[$key]);
return $new;
}
/**
* Restrict visibility to at most the given level
*
* Visibility hierarchy (most to least permissive):
* public > private > no-cache > no-store
*
* Examples:
* - restrictVisibility('private') on 'public' → 'private'
* - restrictVisibility('private') on 'no-cache' → 'no-cache' (already more restrictive)
* - restrictVisibility('no-store') on anything → 'no-store'
*
* @param string $maxVisibility One of: public, private, no-cache, no-store
*/
public function withRestrictedVisibility(string $maxVisibility): static
{
$maxLevel = self::VISIBILITY_LEVELS[strtolower($maxVisibility)] ?? null;
if ($maxLevel === null) {
throw new \InvalidArgumentException(
"Invalid visibility: $maxVisibility. Must be one of: " . implode(', ', array_keys(self::VISIBILITY_LEVELS))
);
}
// Find current visibility level
$currentLevel = 0;
foreach (self::VISIBILITY_LEVELS as $vis => $level) {
if ($this->has($vis) && $level > $currentLevel) {
$currentLevel = $level;
}
}
// If no visibility set or already more restrictive, apply the requested one
if ($currentLevel === 0 || $currentLevel < $maxLevel) {
// Remove any existing visibility directives
$new = $this;
foreach (array_keys(self::VISIBILITY_LEVELS) as $vis) {
$new = $new->without($vis);
}
return $new->with($maxVisibility);
}
// Current is already more restrictive
return $this;
}
/**
* Ensure visibility is at least as restrictive as 'private'
*
* Shorthand for withRestrictedVisibility('private')
*/
public function withPrivate(): static
{
return $this->withRestrictedVisibility('private');
}
/**
* Ensure visibility is 'no-store' (most restrictive)
*
* Also adds no-cache for compatibility.
*/
public function withNoStore(): static
{
return $this
->withRestrictedVisibility('no-store')
->with('no-cache')
->with('must-revalidate');
}
/**
* Cap max-age to at most the given value
*
* If current max-age is lower, keeps the lower value.
* If no max-age set, sets it to the given value.
*/
public function withMaxTtl(int $seconds): static
{
$current = $this->get('max-age');
if ($current === null || $current === true) {
return $this->with('max-age', (string) $seconds);
}
$currentSeconds = (int) $current;
if ($currentSeconds > $seconds) {
return $this->with('max-age', (string) $seconds);
}
return $this;
}
/**
* Cap s-maxage (shared cache TTL) to at most the given value
*/
public function withMaxSharedTtl(int $seconds): static
{
$current = $this->get('s-maxage');
if ($current === null || $current === true) {
return $this->with('s-maxage', (string) $seconds);
}
$currentSeconds = (int) $current;
if ($currentSeconds > $seconds) {
return $this->with('s-maxage', (string) $seconds);
}
return $this;
}
/**
* Set must-revalidate directive
*/
public function withMustRevalidate(): static
{
return $this->with('must-revalidate');
}
/**
* Render to Cache-Control header string
*/
public function __toString(): string
{
$parts = [];
foreach ($this->directives as $name => $value) {
if ($value === true) {
$parts[] = $name;
} else {
$parts[] = "$name=$value";
}
}
return implode(', ', $parts);
}
}