Skip to content
Open
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
24 changes: 24 additions & 0 deletions config/lada-cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,30 @@
*/
'expiration_time' => env('LADA_CACHE_EXPIRATION', 0),

/*
|--------------------------------------------------------------------------
| Per-model TTL overrides
|--------------------------------------------------------------------------
|
| Map of model FQCN to TTL in seconds. Models listed here override the
| global expiration_time. Resolution order (first non-null wins):
| 1. $model->getLadaTtl() if model implements
| Spiritix\LadaCache\Contracts\HasLadaTtl
| 2. config('lada-cache.model_ttls.<FQCN>')
| 3. config('lada-cache.expiration_time') (global)
|
| Examples:
| App\Models\City::class => 86400 * 30, // 30 days for rarely-changing data
| App\Models\Tournament::class => 300, // 5 minutes for hot state
| App\Models\Order::class => null, // fall through to global
|
| 0 = persist forever (cache until tag-based invalidation).
|
*/
'model_ttls' => [
// App\Models\City::class => 86400 * 30,
],

/*
|--------------------------------------------------------------------------
| Cache Granularity
Expand Down
17 changes: 14 additions & 3 deletions src/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,24 @@ public function has(string $key): bool
return (bool) $this->redis->exists($this->redis->prefix($key));
}

public function set(string $key, array $tags, mixed $data): void
/**
* Persist a cached query result under the given key with optional per-call TTL.
*
* TTL resolution:
* - $ttl > 0 : SET key value EX $ttl (expires after $ttl seconds)
* - $ttl = 0 : SET key value (no expiration — persist forever, rely on tag invalidation)
* - $ttl null : fall back to the global expirationTime (which itself follows the same > 0 / = 0 rule)
*
* @param array<string> $tags
*/
public function set(string $key, array $tags, mixed $data, ?int $ttl = null): void
{
$key = $this->redis->prefix($key);
$value = $this->encoder->encode($data);
$effectiveTtl = $ttl ?? $this->expirationTime;

if ($this->expirationTime > 0) {
$this->redis->set($key, $value, 'EX', $this->expirationTime);
if ($effectiveTtl > 0) {
$this->redis->set($key, $value, 'EX', $effectiveTtl);
} else {
$this->redis->set($key, $value);
}
Expand Down
38 changes: 38 additions & 0 deletions src/Contracts/HasLadaTtl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Spiritix\LadaCache\Contracts;

/**
* Marker interface for Eloquent models that want to override the default Lada Cache TTL.
*
* Implementing this on a model lets the cache layer apply a per-model TTL instead of
* the global config('lada-cache.expiration_time'). Resolution order is:
* 1. Model::getLadaTtl() (this interface)
* 2. config('lada-cache.model_ttls.<ClassName>')
* 3. global config('lada-cache.expiration_time')
*
* Easiest way to opt in is via `LadaCacheTrait`, which already provides a
* default `getLadaTtl()` implementation reading from a `public ?int $ladaTtl`
* property declared on the model:
*
* class City extends Model implements HasLadaTtl
* {
* use LadaCacheTrait;
* public ?int $ladaTtl = 86400 * 30; // 30 days
* }
*
* Override the method only when dynamic TTL logic is required.
*
* Semantics of the returned value:
* - null : defer to config-level fallback (model_ttls or global)
* - > 0 : TTL in seconds (e.g., 3600 = 1 hour)
* - 0 : persist forever (cache until tag-based invalidation) — same as
* setting global expiration_time to 0
* - < 0 : same as 0 (forever) — discouraged, prefer 0
*/
interface HasLadaTtl
{
public function getLadaTtl(): ?int;
}
43 changes: 43 additions & 0 deletions src/Database/LadaCacheTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Spiritix\LadaCache\Database;

use ReflectionProperty;
use Spiritix\LadaCache\QueryHandler;

/**
Expand All @@ -12,9 +13,51 @@
* Include this trait on models to route all base queries through the
* Lada Cache-aware query builder, enabling transparent caching and
* invalidation.
*
* Easiest way to override the TTL for a model:
*
* class City extends Model implements HasLadaTtl
* {
* use LadaCacheTrait;
* public ?int $ladaTtl = 86400 * 30; // 30 days
* }
*
* Override `getLadaTtl()` only when dynamic logic is required. If the model
* cannot be edited, fall back to `config('lada-cache.model_ttls.<FQCN>')`.
*/
trait LadaCacheTrait
{
/**
* Default implementation of {@see \Spiritix\LadaCache\Contracts\HasLadaTtl::getLadaTtl()}.
*
* Reads an optional `public ?int $ladaTtl` property declared on the model.
* Models only need to declare the property; method override is reserved
* for cases that require dynamic TTL logic.
*
* The property is intentionally NOT declared on the trait itself. PHP does
* not allow a subclass to redeclare a trait-provided property with a
* different default (fatal error: incompatible composition), so the trait
* supplies only the method and lets the model own the property.
*/
public function getLadaTtl(): ?int
{
if (! property_exists($this, 'ladaTtl')) {
return null;
}

// A typed property declared without a default value (`public ?int $ladaTtl;`)
// throws Error("must not be accessed before initialization") on direct read.
// Guard with Reflection to avoid the crash and fall back to config resolution.
if (! (new ReflectionProperty($this, 'ladaTtl'))->isInitialized($this)) {
return null;
}

/** @var int|null $ttl */
$ttl = $this->ladaTtl;

return $ttl;
}

/** {@inheritDoc} */
protected function newBaseQueryBuilder()
{
Expand Down
10 changes: 8 additions & 2 deletions src/LadaCacheServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ public function provides(): array
'lada.redis',
'lada.cache',
'lada.invalidator',
'lada.ttl_resolver',
'lada.handler',
];
}
Expand All @@ -109,8 +110,13 @@ private function registerSingletons(): void
$this->app->singleton('lada.invalidator', static fn (Application $app) => new Invalidator($app->make('lada.redis'))
);

$this->app->singleton('lada.handler', static fn (Application $app) => new QueryHandler($app->make('lada.cache'), $app->make('lada.invalidator'))
);
$this->app->singleton('lada.ttl_resolver', static fn () => new TtlResolver);

$this->app->singleton('lada.handler', static fn (Application $app) => new QueryHandler(
$app->make('lada.cache'),
$app->make('lada.invalidator'),
$app->make('lada.ttl_resolver'),
));
}

/**
Expand Down
4 changes: 3 additions & 1 deletion src/QueryHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ final class QueryHandler
public function __construct(
private readonly Cache $cache,
private readonly Invalidator $invalidator,
private readonly TtlResolver $ttlResolver,
) {}

public function setBuilder(QueryBuilder $builder): self
Expand Down Expand Up @@ -156,7 +157,8 @@ public function cacheQuery(Closure $queryClosure): array

if ($cached === null) {
$cached = $queryClosure();
$this->cache->set($key, $tags, $cached);
$ttl = $this->ttlResolver->resolve($this->builder->getModel());
$this->cache->set($key, $tags, $cached, $ttl);
} else {
// Self-heal tag membership inconsistencies by idempotently adding the key to each tag set.
$this->cache->repairTagMembership($key, $tags);
Expand Down
48 changes: 48 additions & 0 deletions src/TtlResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace Spiritix\LadaCache;

use Illuminate\Database\Eloquent\Model;
use Spiritix\LadaCache\Contracts\HasLadaTtl;

/**
* Resolves the effective Lada Cache TTL for a given Eloquent model.
*
* Resolution order (first non-null wins):
* 1. Model implements HasLadaTtl → $model->getLadaTtl()
* 2. config('lada-cache.model_ttls.<FQCN>')
* 3. null → caller defers to global config('lada-cache.expiration_time')
*
* Octane-safe: no mutable state, config is read once at construction.
*/
final class TtlResolver
{
/** @var array<class-string, ?int> */
private readonly array $modelTtls;

public function __construct()
{
/** @var array<class-string, ?int> $configured */
$configured = (array) config('lada-cache.model_ttls', []);
$this->modelTtls = $configured;
}

public function resolve(?Model $model): ?int
{
if ($model === null) {
return null;
}

if ($model instanceof HasLadaTtl) {
$ttl = $model->getLadaTtl();

if ($ttl !== null) {
return $ttl;
}
}

return $this->modelTtls[$model::class] ?? null;
}
}
Loading