Add PHPStan rules for Hypervel's long-lived workers
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()orTransientservice therefore kept the first request's object for every later request. The container gainsresolvedShared(), 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. dateis bound by the framework. TheDatefacade used to callswap()on first use whendatewasn't bound, which registered a container instance in the middle of a request.FoundationServiceProvidernow bindsdatetoDateFactorywithsingletonIf(). ADatefacade subclass'sDEFAULT_FACADEtherefore only applies whiledateis unbound; an application should binddateto 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-neutralConnection, notConnectionInterface. Every built-in andDB::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 andinstanceofchecks. Laravel's contract documentsConnectionInterface, so this is listed in the Database README and the porting guide. - Callback types. The pooled filesystem's
withDriver(),withAdapter()andwithClient(), the RediswithPinnedConnection()methods and the SQS pool'swithConnection()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.facilityreadLOG_SYSLOG_FACILITYwith the integerLOG_USERas 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 aslocal0. The default is now'user', which Monolog maps toLOG_USER, in the framework config and both skeletons. LoadEnvironmentVariablesno longer callshttp_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.
| Check | Reports | Parameter |
|---|---|---|
| Restricted calls | Calls 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 calls | Calls that reach a method marked Unsupported, including its message. | none |
| Borrowed resources | A 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 transactions | commit() or rollBack() that ends a transaction transaction() manages before its closure returns, and rollBack(0). | checkManagedTransactions |
| Native request APIs | header(), setcookie(), session_*(), $_GET, $_SESSION and the other PHP functions and superglobals that act on the process instead of the request. | noNativeRequestApis |
| Discarded dates | An immutable date modifier whose result is thrown away. | checkDiscardedDates |
| Mutable dates | Creating a mutable Carbon. | noMutableDates |
| Environment casts | A config file env() read with a boolean, integer or float default that isn't cast. | checkConfigEnvCasts |
| Typed config getters | config('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 observers | observe() in a service provider for a model the application owns, which should use ObservedBy. | checkObserverAttributes |
| Migration column types | json(), enum() and set() columns. Off by default, since existing migrations shouldn't be edited. | checkMigrationColumnTypes |
| Binding cycles | Binding a container key to a class that is an alias of that same key, which never finishes resolving. | checkBindingCycles |
| Raw coroutines | Coroutines started directly through Swoole or the engine layer, where an uncaught exception or cancellation stops the worker. | noRawCoroutines |
| Socialite providers | Reading or writing a provider property whose per-request value lives in coroutine context. | checkSocialiteProviders |
| Swoole tables | new Swoole\Table in a service provider's register() or boot(), which every process runs. | checkSwooleTables |
| Conditional bindings | A BindWhen condition that reads the current request or user, which would bind that request's choice for the worker's lifetime. | checkBindWhenConditions |
| Swallowed cancellation | A 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
OnReceiveandOnClose, and Eloquent observers registered withobserve()orObservedBy, are analyzed as code that runs during an operation. The existing worker-state checks use these contexts too. - Shared receivers. A
whenSharedrestriction 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 asapp('router'), or a dependency the container injects into a controller action, a job handler or a readonly promoted constructor property. A factory you create withnewis 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.mdsays 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. dateis now bound inFoundationServiceProvider; aDatefacade subclass'sDEFAULT_FACADEonly applies whiledateis unbound.ConnectionResolverInterface::connection()and its implementations now return the driver-neutralConnectioninstead ofConnectionInterface, removing severalinstanceofchecks 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 integerLOG_USER. LoadEnvironmentVariablesno longer callshttp_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-dependentBindWhenconditions. - 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.