Replace lifetime docblock tags with method attributes

Merged
BI
1d ago
+5479-2467

This adds hypervel/attributes, a small package of method attributes, and uses it to replace the "Boot-only.", "Tests only." and "Boot or tests only." docblock warnings across the framework.

Hypervel workers are long-lived, so some methods are only safe to call while the application boots or in tests. Until now, those methods said so in prose at the start of a docblock paragraph. That works for someone reading the source, but it isn't reliable for tools. The worker-state analysis matched the tags with a regex, which also matched conditional prose such as "Boot-only when setting", and there was no way to say that only some calls to a method are restricted. Attributes carry the same information as exact metadata that PHPStan reads through reflection.

The attributes don't change runtime behavior. In this PR, the existing worker-state analysis uses them to skip writes in marked methods, as it did with the tags. Checking calls to marked methods is left to a follow-up that builds on this.

The attributes

BootOnly, TestsOnly and BootOrTestsOnly live in Hypervel\Support\Attributes. Each takes optional arguments for methods where only some calls are restricted:

  • whenNotDefault: 'parameter' restricts calls that pass that parameter a value other than its default. Relation::morphMap() uses it, so calling it without a map to read the current map is still fine at runtime.
  • whenProvided: 'parameter' restricts any call that passes that parameter. This is for methods that treat an explicit default differently from an omitted argument, such as Passport::rememberConsentFor(), where an explicit null clears the duration.
  • whenShared: true restricts calls only on the worker-shared instance, for methods that change only their own object. An HTTP client factory you create yourself can set its own global options; the application's shared factory should only be configured while booting.

A parameter condition can be combined with whenShared, in which case a call is restricted only when both apply.

Unsupported marks a retained public method that always throws, such as a Symfony or PSR method that doesn't fit Hypervel's runtime. It takes an optional message pointing to the supported alternative:

#[Unsupported('Return the response from your route or controller so the server can send it.')]
public function send(bool $flush = true): never

Base methods that drivers override, extension hooks, deliberate control-flow throws and methods that only reject some calls aren't marked.

The attributes are a separate package, rather than part of hypervel/support, because PHPStan drops an attribute that has arguments when the attribute class isn't installed. Every package using one has to depend on it, and several of those packages, such as container, contracts and collections, don't depend on Support. The package requires nothing but PHP and shares the Hypervel\Support namespace, the same way hypervel/macroable and hypervel/conditionable do.

Converting the existing warnings

Each tagged method was checked against what it actually changes instead of being converted mechanically. Most keep their restriction, but the conversion also corrected a number of them:

  • Methods that only change their own instance use whenShared, and their docblocks now say they affect the shared instance rather than every instance.
  • Caches that are rebuilt on demand, such as the view finder and routing reflection caches, are no longer restricted. Neither are the broadcast, queue, mail and filesystem manager purge() methods, whose documented use is recovering broken pooled resources at runtime.
  • Methods that forward to an injected object, such as event registration wrappers that receive their dispatcher, are left unmarked. Whether they touch shared state depends on the object passed in, and the target's own mutators carry the marker.
  • Methods that directly call a marked method carry the same marker, including facade fake() methods and service provider registration helpers.
  • Contracts and their implementations carry matching markers, since PHP attributes aren't inherited.
  • Registration that happens lazily on first use with stable definitions, such as Pennant class features and Eloquent model boot hooks, stays unmarked.

Each docblock keeps its explanation of what goes wrong; only the tag phrase is gone.

Context state owned by the right object

Checking each marker against its real behavior turned up places where coroutine context state was keyed by a fixed name instead of by the object that owns it. Independent instances could then read, replace or clear each other's state:

  • Container: scoped instances and resolution state used fixed context keys. A second container could return or clear the first container's scoped instances, and resolving through another container inside a binding could throw a CircularDependencyException. Each container now has its own lazily allocated key prefix. A cloned container gets a new prefix and starts with copies of the scoped instances already resolved in the current coroutine.
  • Guards: all six guards stored per-request users, credentials and caches under keys built from the guard name. A guard created later with the same name, for example after AuthManager::forgetGuards(), picked up the previous guard's user and cached credentials. Guard keys now belong to the guard instance. Session keys and remember cookies are unchanged, so a replacement guard still reauthenticates from a valid session.
  • Cache: managers with the same store name shared one memo() repository, and forgetDriver() evicted another manager's memo. Memo keys now belong to the manager. Cloning a cache repository also cloned its ArrayStore, which kept the original context keys, so writes through the clone changed the original. Cloned array stores now get their own keys, starting with copies of the current values and locks. A cloned tagged cache likewise keeps its tags on its cloned store, so flushing the copy no longer resets the original's tags.
  • Redis: pinned connections and deferred-release owners were keyed by connection name alone, so proxies on different pool managers shared a pin and could release each other's connections. The keys now include the owning pool manager. Proxies on the same manager still share their pin.

Only the keys change, not how values are stored, so child coroutines inherit context as before. The fixed-key constants (Container::SCOPED_CONTEXT_PREFIX and RESOLUTION_STATE_CONTEXT_KEY, JwtGuard::GUARD_CONTEXT_KEY_PREFIX, RedisProxy::CONNECTION_CONTEXT_PREFIX) and the getContextStateKey() helpers on SessionGuard and JwtGuard are removed.

Other changes

  • The Queue facade's popUsing() accepts null to remove a callback, matching Worker::popUsing().
  • Engine\Http\Stream::seek() has native int parameter types, matching PSR-7 v2.
  • The worker-state analysis reads the attributes through PHPStan reflection instead of matching docblock text.
  • The Static Analysis documentation has a new Method Attributes section, and AGENTS.md describes the attributes in place of the tags.
  • docs/todo.md lists the hypervel/attributes split repository and Packagist entry, which need to exist before release.

Validation

Formatting, PHPStan and the analyzer test suite pass. A standalone copy of the package was installed on its own and every attribute instantiated from it, and each package that uses an attribute declares the dependency. The context-key fixes have regression tests covering independent instances, invalidation, cloning, child coroutines and reauthentication after a guard is replaced.

[!NOTE]

Replace lifetime docblock tags with BootOnly/TestsOnly method attributes

  • Adds a new hypervel/attributes package with BootOnly, BootOrTestsOnly, TestsOnly, and Unsupported method attributes, and replaces textual "Boot only"/"Tests only" docblock wording across nearly every framework package. Attributes carry conditions such as whenShared, whenNotDefault, and whenProvided, and expose the metadata through PHP reflection. hypervel/attributes ^0.4 is added as a dependency everywhere, and the PHPStan coroutine rules in WorkerStateHelper.php now detect lifetime restrictions via reflected attributes instead of docblock tag matching.
  • Coroutine context keys become per-instance instead of name-based. Container, CacheManager, PoolManager, RedisProxy, and the auth guards (GuardHelpers::contextKey, SessionGuard, TokenGuard, JwtGuard, Sanctum/Passport guards) now generate unique context-key prefixes per instance. A new guard with the same name no longer inherits the previous guard's user.
  • Container::__clone and ArrayStore/TaggedCache/MemoizedTaggedCache clone hooks copy scoped instances and memoized state into fresh context keys, so clones start with current state but stay independent.
  • Adds TagSet::withStore(), makes Queue::popUsing() accept null to remove the callback, and makes MemoizedTaggedCache::flush() clear its own memoized values.
  • Behavioral Change: context keys derived from guard/pool/container names change, so code reading coroutine context directly with the old hard-coded keys (as several tests previously did) breaks; CacheManager::forgetDriver() now string-casts numeric configured store names; flushState-adjacent detection in PHPStan rules keys off attributes, so any out-of-tree methods relying on docblock tags for detection are no longer recognized.

Macroscope summarized 8db14b5.