Add PHPStan rules for Hypervel's long-lived workers

BI
1h ago
+12733-972

This adds a set of PHPStan rules for mistakes that are easy to make in a long-lived Swoole worker and hard to see in review: calling a boot-only method while handling a request, letting a pooled connection escape the callback that borrowed it, swallowing a coroutine cancellation, creating a Swoole table in every process, and similar. It builds on the lifetime and Unsupported method attributes, which mark the methods these rules check.

Laravel code mostly runs unchanged on Hypervel, but a worker serves many requests and coroutines at once, so some patterns that are harmless under PHP-FPM leak state or misbehave here. The framework already documents these. These rules turn that guidance into diagnostics, each with a fix and, where there is one, the legitimate exception that should be ignored with a reason.

A few runtime changes came out of the work, so they're described first. They're in their own commits.

Runtime changes

  • Facade roots follow the container's lifetimes. A facade used to cache whatever it resolved for the whole worker, unless the service was scoped. A facade over a bind() or Transient service therefore kept the first request's object for every later request. The container gains resolvedShared(), which reports whether it holds a worker-shared instance, including auto-singletons. Facades cache those for the worker as before. Other roots are kept per coroutine, so each request gets its own and concurrent requests never share one. Clearing a facade's resolved instance now makes later facade calls resolve again in every coroutine.
  • date is bound by the framework. The Date facade used to call swap() on first use when date wasn't bound, which registered a container instance in the middle of a request. FoundationServiceProvider now binds date to DateFactory with singletonIf(). A Date facade subclass's DEFAULT_FACADE therefore only applies while date is unbound; an application should bind date to its factory instead. This is listed in the Support README's differences.
  • Database resolvers return Connection. ConnectionResolverInterface::connection() and the resolvers implementing it now declare the driver-neutral Connection, not ConnectionInterface. Every built-in and DB::extend() connection already is one, and Eloquent and the migrator already required it, so a resolver returning anything else was already broken. The change removes several ignores and instanceof checks. Laravel's contract documents ConnectionInterface, so this is listed in the Database README and the porting guide.
  • Callback types. The pooled filesystem's withDriver(), withAdapter() and withClient(), the Redis withPinnedConnection() methods and the SQS pool's withConnection() are now generic over their callback's return type. The filesystem methods also accept a callback that narrows its parameter to the disk's concrete driver, adapter or client.
  • Shipped logging config uses a named syslog facility. logging.syslog.facility read LOG_SYSLOG_FACILITY with the integer LOG_USER as its default. An environment variable is always a string, and Monolog only accepts facility names as strings, so the variable has to hold a name such as local0. The default is now 'user', which Monolog maps to LOG_USER, in the framework config and both skeletons.
  • LoadEnvironmentVariables no longer calls http_response_code(). It was left over from Laravel's FPM bootstrap and does nothing in Hypervel, which bootstraps from the CLI before any response exists.
  • Testbench exposes the vendor directory its runtime copy links. See the result cache section below.

The rules

Each rule has its own section in the static analysis docs, with an example, the fix and its exceptions. Every rule except unsupported calls can be turned off with a parameter.

CheckReportsParameter
Restricted callsCalls to methods marked BootOnly, TestsOnly or BootOrTestsOnly from request, job or operation code, applying each marker's conditions. Also markers whose arguments would misstate the restriction.checkRestrictedCalls
Unsupported callsCalls that reach a method marked Unsupported, including its message.none
Borrowed resourcesA pooled Redis connection, SQS client, filesystem driver or pinned PDO that outlives the callback that borrowed it: returned, stored, or captured by a coroutine or timer.checkBorrowedResources
Managed transactionscommit() or rollBack() that ends a transaction transaction() manages before its closure returns, and rollBack(0).checkManagedTransactions
Native request APIsheader(), setcookie(), session_*(), $_GET, $_SESSION and the other PHP functions and superglobals that act on the process instead of the request.noNativeRequestApis
Discarded datesAn immutable date modifier whose result is thrown away.checkDiscardedDates
Mutable datesCreating a mutable Carbon.noMutableDates
Environment castsA config file env() read with a boolean, integer or float default that isn't cast.checkConfigEnvCasts
Typed config gettersconfig('key') or get() for a key that always holds one type, which should use string(), integer() and so on. Only runs when analyzing an application.checkTypedConfigGetters
Model observersobserve() in a service provider for a model the application owns, which should use ObservedBy.checkObserverAttributes
Migration column typesjson(), enum() and set() columns. Off by default, since existing migrations shouldn't be edited.checkMigrationColumnTypes
Binding cyclesBinding a container key to a class that is an alias of that same key, which never finishes resolving.checkBindingCycles
Raw coroutinesCoroutines started directly through Swoole or the engine layer, where an uncaught exception or cancellation stops the worker.noRawCoroutines
Socialite providersReading or writing a provider property whose per-request value lives in coroutine context.checkSocialiteProviders
Swoole tablesnew Swoole\Table in a service provider's register() or boot(), which every process runs.checkSwooleTables
Conditional bindingsA BindWhen condition that reads the current request or user, which would bind that request's choice for the worker's lifetime.checkBindWhenConditions
Swallowed cancellationA catch that handles a cancellation as an ordinary failure around a database query, Redis command, HTTP request or wait in request, job or operation code.checkCancellationHandling

The framework's own reports are fixed or, where the code is intentional, ignored with a reason: for example the coroutine primitives themselves, Horizon's ignore_user_abort(), and Sentry's spotlight setting, which accepts a boolean or a URL.

Shared analysis

Several rules need to know where code runs and whether a value is the worker's shared instance.

  • More runtime contexts. Request and job contexts now include listeners for the framework's request, routing and queue job events, and queued listeners and observers, which run as jobs. Listeners for server events such as OnReceive and OnClose, and Eloquent observers registered with observe() or ObservedBy, are analyzed as code that runs during an operation. The existing worker-state checks use these contexts too.
  • Shared receivers. A whenShared restriction is only reported when the call is known to reach the shared instance: through a facade whose service the container shares, a named application-container resolution such as app('router'), or a dependency the container injects into a controller action, a job handler or a readonly promoted constructor property. A factory you create with new is not reported.
  • Facade methods without running application code. The rules find the method a facade call reaches from its accessor and the booted container's registration, without resolving the facade or running factories.

Configuration types only for the application repository

Configuration value types now apply only to reads from the application's repository: the config() helper, the Config facade, and calls such as app('config')->get(...). A repository received as a parameter or property, or built with new Repository($items), may hold other values, so it keeps its declared return types. Larastan applies the application's configuration types to get() on any repository. This is listed in the PHPStan README's differences.

Result cache for package analysis

Package analysis boots a Testbench copy of the skeleton that is deleted when the process exits. The result cache recorded the copy's Composer metadata by its path inside that copy, so every warm run saw it as missing and reanalyzed everything that depends on the booted application, about a third of this repository. The cache now records the metadata where Testbench takes it from: the skeleton's vendor directory and the working package's, by their own paths, including whether the skeleton's vendor directory exists. Testbench's Bootstrapper::runtimeVendorPath() returns the working vendor directory for this. A warm composer analyse of this repository now reanalyzes nothing and takes a few seconds instead of about forty.

Docs

  • The static analysis docs have a section for each rule and an entry in the configuration and identifier tables. The Method Attributes section links to the restricted and unsupported method sections.
  • The facades docs describe the new lifetimes, and the porting guide covers the native request functions.
  • AGENTS.md says PHPStan checks the lifetime attributes, rewords binding pattern 3 to say what explicit registration changes, and corrects the description of binding an alias.

Validation

Formatting, PHPStan, the analyzer test suite, the Testbench suite and the Support tests pass. Each rule has tests for what it reports, the allowed alternatives and its documented exceptions, and the result cache changes have warm-cache tests. The skeleton was installed the way CI installs it and analyzed with these rules, with no reports.

Compared with 0.4, a cold composer analyse of this repository is a few percent slower. Turning the new rules off didn't measurably change that, and warm runs are much faster because of the result cache fix.


Summary by cubic

Adds 17 PHPStan rules that catch mistakes unique to Hypervel's long-lived Swoole workers: boot-only methods called while handling a request, pooled connections escaping the callback that borrowed them, and swallowed coroutine cancellations. A few runtime changes make the rules accurate, and a result-cache fix makes warm package analysis reanalyze nothing.

Runtime changes

  • Facade roots now follow container lifetimes: facades over bind() or transient services keep a per-coroutine root instead of caching the first request's object for the worker.
  • date is now bound in FoundationServiceProvider; a Date facade subclass's DEFAULT_FACADE only applies while date is unbound.
  • ConnectionResolverInterface::connection() and its implementations now return the driver-neutral Connection instead of ConnectionInterface, removing several instanceof checks and ignores.
  • Pooled filesystem, Redis, and SQS borrowing callbacks are generic over their return type.
  • Shipped logging config uses the named syslog facility 'user' instead of the integer LOG_USER.
  • LoadEnvironmentVariables no longer calls http_response_code().
  • Testbench's result cache tracks Composer metadata through the vendor directory it links, so warm package-analysis runs reuse results instead of reanalyzing about a third of the repository.

The rules

  • Also reports native request APIs, discarded and mutable dates, uncast config env() defaults, untyped config getters, observe() for owned models, binding cycles, raw coroutines, Socialite provider properties, Swoole tables created in providers, and request-dependent BindWhen conditions.
  • Every rule except unsupported calls has a configuration switch, and each documents its legitimate exception.
  • Rules judge code against the booted container's registrations and request, job, and operation contexts, and only flag worker-shared receivers with evidence the receiver is the shared instance.
  • Static analysis and porting docs cover each rule with an example, the fix, and its exceptions.

[!NOTE]

Add PHPStan rules for Hypervel's long-lived workers and per-coroutine facade roots

  • Adds many PHPStan rules for worker safety: restricted calls (RestrictedCallRule), swallowed cancellation (SwallowedCancellationRule), borrowed-resource escapes, raw coroutines, mutable/discarded dates, managed transactions, binding cycles, request-dependent bindings, and more
  • Refactors worker-state analysis into a shared WorkerStateHelper/WorkerStateAnalysis model with a rewritten WorkerLifetimes that tracks event, listener, observer, and injection contexts
  • Changes Facade::resolveFacadeInstance to keep cached roots per coroutine for non-shared, non-scoped services, and adds Container::resolvedShared() (contract included) so the facade knows when a root is worker-shared
  • Narrows the database connection contract: resolvers and DatabaseQueue::getDatabase() now return Connection instead of ConnectionInterface (docs updated); also binds date to DateFactory in FoundationServiceProvider
  • Behavioral Change: check ConnectionResolverInterface::connection() return types (custom resolvers and test mocks must return Connection), facade root caching semantics in Facade.php, and Date::resolveFacadeInstance which now caches the default instance directly without swap()

Macroscope summarized c329d6f.