Skip to content

feat: add per-model TTL via HasLadaTtl interface#152

Open
acaliskol wants to merge 2 commits into
spiritix:masterfrom
acaliskol:feat/per-model-ttl
Open

feat: add per-model TTL via HasLadaTtl interface#152
acaliskol wants to merge 2 commits into
spiritix:masterfrom
acaliskol:feat/per-model-ttl

Conversation

@acaliskol

@acaliskol acaliskol commented May 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds per-model cache TTL overrides without breaking the global expiration_time default. Models opt in via the new HasLadaTtl interface, and LadaCacheTrait provides a default getLadaTtl() implementation so the common case is a single property declaration:

// 1) Property — easiest, common case (recommended)
class City extends Model implements HasLadaTtl
{
    use LadaCacheTrait;
    public ?int $ladaTtl = 86400 * 30; // 30 days
}

// 2) Method override — when dynamic logic is required
class Tournament extends Model implements HasLadaTtl
{
    use LadaCacheTrait;
    public function getLadaTtl(): ?int
    {
        return $this->is_live ? 60 : 3600;
    }
}

// 3) Static config — for models you can't (or don't want to) modify
// config/lada-cache.php
'model_ttls' => [
    App\Models\Tournament::class => 300,   // 5 minutes for hot state
    App\Models\Order::class      => null,  // defer to global
],

Resolution order

First non-null wins:

  1. Model implements HasLadaTtl\$model->getLadaTtl()
    • Default impl provided by LadaCacheTrait reads public ?int \$ladaTtl
    • Models can override the method instead for dynamic logic
  2. config('lada-cache.model_ttls.<FQCN>')
  3. config('lada-cache.expiration_time') global

TTL value semantics

Value Meaning
null Defer to next layer in resolution chain
> 0 TTL in seconds
0 Persist forever (cache until tag invalidation) — same as existing expiration_time = 0 behavior
< 0 Same as 0 (forever) — discouraged, prefer 0

Why the property is on the model, not on the trait

PHP does not allow a subclass to redeclare a trait-provided property with a different default value:

Fatal error: City and LadaCacheTrait define the same property
(\$ladaTtl) in the composition. However, the definition differs and
is considered incompatible.

So LadaCacheTrait cannot ship public ?int \$ladaTtl = null; itself. Instead the trait supplies only the method and lets each model declare the property with whatever default it needs. property_exists() inside the trait method handles models that implement HasLadaTtl without declaring the property.

Defensive read for uninitialized typed properties

public ?int \$ladaTtl; (typed, no default) is legal PHP. property_exists() returns true, but reading the property before any assignment throws:

Error: Typed property City::\$ladaTtl must not be accessed before initialization

The trait method uses ReflectionProperty::isInitialized() to detect this state and returns null, letting the resolver fall through to the config-level fallback instead of crashing the request.

Files

  • src/Contracts/HasLadaTtl.php — new interface
  • src/Database/LadaCacheTrait.php — default getLadaTtl() reading \$ladaTtl property with uninitialized guard
  • src/TtlResolver.php — new Octane-safe resolver service (singleton, no mutable state)
  • src/Cache.phpset(..., ?int \$ttl = null) now accepts per-call TTL. Backward compatible default.
  • src/QueryHandler.php — resolves model TTL before each cache->set()
  • src/LadaCacheServiceProvider.php — registers lada.ttl_resolver singleton
  • config/lada-cache.php — new model_ttls config section with examples

Tests

13 tests in tests/Integration/Cache/PerModelTtlTest.php:

TtlResolver fallback chain (interface-based):

  • null model → null
  • no override present → null
  • config-only override → returns config value
  • interface override → wins over config
  • interface returns null → falls back to config
  • interface returns 0 → explicitly persisted as 0 (not null)

LadaCacheTrait property-based default getLadaTtl():

  • property only, no method override → property value is used
  • property defaults to null → falls back to config
  • implements interface without declaring property → falls back to config
  • typed property with no default value (uninitialized) → falls back to config

Cache::set TTL semantics:

  • explicit TTL is honored (verified via Redis TTL command)
  • ttl=0 → Redis TTL returns -1 (no expiration)
  • ttl=null → falls back to global expiration_time

Compatibility

  • Cache::set() 4th parameter is optional with ?int \$ttl = null default → no break for existing callers
  • New lada.ttl_resolver singleton is additive; provider's provides() array updated
  • Models without the interface and without a model_ttls entry behave exactly as before
  • Models that override getLadaTtl() explicitly continue to take precedence over the trait's default impl
  • Tested under PHPStan level 7

Adds per-model cache TTL overrides without breaking the global
`expiration_time` default. Models can opt-in either via the new
`HasLadaTtl` interface (most flexible) or via a static config map.

Resolution order (first non-null wins):
1. Model implements `HasLadaTtl` → `$model->getLadaTtl()`
2. `config('lada-cache.model_ttls.<FQCN>')` static map
3. `config('lada-cache.expiration_time')` global default

Semantics of TTL values:
- `null`   defer to fallback layer
- `> 0`    TTL in seconds
- `0`      persist forever (cache until tag invalidation) — matches
           how global `expiration_time = 0` already behaves
- `< 0`    same as 0 (forever) — discouraged

Why an interface and not a `$ladaTtl` public property:
- Traits cannot enforce property type across consumers; a public
  property would collide with any model column named `ladaTtl`
- Octane-safe: no mutable shared state on a long-lived model instance
- PHPStan-narrowable: `instanceof HasLadaTtl` gives full type info,
  whereas `property_exists($model, 'ladaTtl')` does not narrow

Files:
- src/Contracts/HasLadaTtl.php   - new interface
- src/TtlResolver.php            - new Octane-safe resolver service
- src/Cache.php                  - `set(..., ?int $ttl = null)` now accepts per-call TTL
- src/QueryHandler.php           - resolves model TTL before each cache write
- src/LadaCacheServiceProvider.php - registers `lada.ttl_resolver` singleton
- config/lada-cache.php          - new `model_ttls` config section
- tests/Integration/Cache/PerModelTtlTest.php - 9 tests covering resolver +
  Cache::set TTL semantics (explicit / zero / null fallback)
Models can now declare `public ?int $ladaTtl = X;` instead of writing a
custom `getLadaTtl()` method. LadaCacheTrait now provides a default
implementation that reads from the property, so the typical case becomes
a single line:

    class City extends Model implements HasLadaTtl
    {
        use LadaCacheTrait;
        public ?int $ladaTtl = 86400 * 30; // 30 days
    }

The method override remains available for dynamic TTL logic.

Why the property is declared on the model and not on the trait: PHP does
not allow a subclass to redeclare a trait-provided property with a
different default value (Fatal error: incompatible composition). The
trait therefore supplies only the method and lets the model own the
property.

Defensive read: a typed property without a default value
(`public ?int $ladaTtl;`) throws Error("must not be accessed before
initialization") on direct access. The trait guards with
ReflectionProperty::isInitialized() so the resolver falls through to the
config-level fallback instead of crashing.

Backwards compatible: existing models that override getLadaTtl() are
untouched; the trait method is a default override candidate.

Tests added in tests/Integration/Cache/PerModelTtlTest.php cover:
  - PropertyOnly       — property only, no method override
  - PropertyNull       — property defaults to null, falls back to config
  - PropertyMissing    — implements HasLadaTtl without declaring property
  - PropertyUninit     — typed property with no default value
@spiritix

Copy link
Copy Markdown
Owner

Hi @acaliskol, thanks for your contribution! What are the use cases for which you think this new feature would be helpful? In my opinion it kind of contradicts with the main promise of Lada Cache that the user shouldn't need to deal with invalidation.

@spiritix

Copy link
Copy Markdown
Owner

Tagging some contributors here to discuss this feature proposal. Let me know what you guys think! Is this the right approach in your opinion? I'd like to get the community more involved for directional changes like this.

@kontainer-dam-pim @Tim-streamline @zgetro @duyphuongn @MGApcDev @michael-rubel @ogunsakin01@diegotibi

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants