feat: framework refactor + decouple from Hyperf#349
feat: framework refactor + decouple from Hyperf#349binaryfire merged 3804 commits intohypervel:0.4from
Conversation
|
@albertcht To illustrate how much easier it will be to keep Hypervel in sync with Laravel after this refactor, I asked Claude how long it would take to merge laravel/framework#58461 (as an example) into this branch. This is what it said: So just 5-10 minutes of work with the help of AI tooling! Merging individual PRs is inefficient - merging releases would be better. I can set up a Discord channel where new releases are automatically posted via webhooks. Maybe someone in your team can be responsible for monitoring that channel's notifications and merging updates ever week or 2? I'll only be 1-2 hours of work once the codebases are 1:1. We should be diligent about staying on top of merging updates. Otherwise we'll end up in in the same as Hyperf - i.e. the codebase being completely out of date with the current Laravel API. |
|
Hi @binaryfire , Thank you for submitting this PR and for the detailed explanation of the refactor. After reading through it, I strongly agree that this is the best long-term direction for Hypervel. Refactoring Hypervel into a standalone framework and striving for 1:1 parity with Laravel will indeed solve the current issues regarding deep coupling with Hyperf, maintenance difficulties, outdated versions, and inefficient AI assistance. While this is a difficult step, it is absolutely necessary for the future of the project. Regarding this refactor and the planning for the v0.4 branch, I have a few thoughts to verify with you:
Thank you again for dedicating so much effort to driving this forward; this is a massive undertaking. Let's move forward gradually on this branch with ongoing Code Reviews. |
|
Hi @albertcht Thanks for the detailed response! I'm glad we're aligned on the direction. Let me address each point:
Let me know your thoughts! |
8cec3bf to
bfffa6f
Compare
|
Hi @albertcht. The All the Laravel tests have been ported over and are passing (the unit tests, as well as the integration tests for MySQL, MariaDB, Postgres and SQLite). I've implemented Context-based coroutine safety, static caching for performance and modernised all the types. The code passes PHPStan level 5. Let me know if there's anything I've missed, if you have any ideas or you have any questions. The other packages aren't ready for review yet - many of them are mid-migration and contain temporary code. So please don't review the others yet :) I'll let you know when each one is ready. A few points:
|
80f3ef2 to
94c115e
Compare
|
@albertcht The following packages are ready for review. I've modernised typing, optimised the code, added more tests (including integration tests) and fixed several bugs.
I've also ported https://github.com/friendsofhyperf/redis-subscriber into the Redis package. The subscription methods were all blocking - now they're coroutine friendly. With the previous implementation, if you wrapped
The approach follows the same pattern suggested in hyperf/hyperf#4775 (https://github.com/mix-php/redis-subscriber, which Deeka ported to https://github.com/friendsofhyperf/components). I.e. a dedicated raw socket connection with This is a good article re: this issue for reference: https://openswoole.com/article/redis-swoole-pubsub |
|
Hi @albertcht! The new This is Swoole-optimised version of Laravel's IoC Container, replacing Hyperf's container. The goal: give Hypervel the complete Laravel container API while maintaining performance parity with Hyperf's container and full coroutine safety for Swoole's long-running process model. Why replace Hyperf's container?Hyperf's container is minimal. It exposes
Also, the API is very different to Laravel's. This makes it difficult to port Laravel code or use Laravel's service provider patterns without shimming everything. The new container closes that gap completely and makes interacting with the container much more familiar to Laravel devs. It also means that our package and test code will be closer to 1:1 with Laravel now. APIThe new container implements the full Laravel container contract:
It also supports closure return-type bindings (register a binding by returning a typed value from a closure, including union types), Key API difference from HyperfLike Hyperf's Auto-singletoned instances are stored in a separate Attribute-based injection16 contextual attributes are included, providing declarative dependency injection:
Example: class OrderService
{
public function __construct(
#[Config('orders.tax_rate')] private float $taxRate,
#[Tag('payment-processors')] private array $processors,
#[Authenticated] private User $user,
) {}
}PerformanceBuild recipe cachingConstructor parameters are analyzed via reflection once per class and cached as Method parameter caching
Reflection caching
Hot-path optimizations
Performance vs HyperfThe singleton cache-hit path does marginally more work than Hyperf's single Coroutine safetyAll per-request state is stored in coroutine-local
Circular dependency detection uses two complementary mechanisms:
All transient Context state is cleaned up in Scoped instance cleanup is handled consistently across all invalidation paths. Tests~220 tests:
Everything passes at PHPStan level 5. Let me know what you think |
Add psy/psysh ^0.12.22 dependency, PSR-4 autoload, replace entry, and provider registration. Remove friendsofhyperf/tinker suggest.
Classmap, app classes, and vendor class for ClassAliasAutoloader tests.
Tests aliasing, namespace exclusion, vendor exclusion, and vendor whitelisting.
Verifies command binding and config merge via testbench.
Tests --execute success, --execute failure, and verifies evaluated code runs inside a coroutine context.
Rename fixture classes from generic names (Bar, Qux, Three) to unique names (TinkerBar, TinkerQux, TinkerThree). class_alias is permanent for the process lifetime, so generic global aliases collide with other tests in the same paratest worker.
WebSocket server entries should be registered by their respective packages (e.g. Reverb) rather than manually uncommented in config.
Routes can now be scoped to a specific server port via port(). Port is stored in the action array (like domain) and flows through group merging. PortValidator added to the matching chain.
…tion Post-match port check rejects routes scoped to a different port. Compiled port map enables O(1) conflict detection when adding dynamic routes after compilation.
Absolute URLs now use the route's port() instead of the current request port. Uses the route's effective scheme when deciding whether to omit default ports (80/443). Preserves forced root path when replacing the port.
Hi @albertcht. This isn't ready yet but I'm opening it as a draft so we can begin discussions and code reviews. The goal of this PR is to refactor Hypervel to be a fully standalone framework that is as close to 1:1 parity with Laravel as possible.
Why one large PR
Sorry about the size of this PR. I tried spreading things across multiple branches but it made my work a lot more difficult. This is effectively a framework refactor - the database package is tightly coupled to many other packages (collections, pagination, pool) as well as several support classes, so all these things need to be updated together. Splitting it across branches would mean each branch needs multiple temporary workarounds + would have failing tests until merged together, making review and CI impractical.
A single large, reviewable PR is less risky than a stack of dependent branches that can't pass CI independently.
Reasons for the refactor
1. Outdated Hyperf packages
It's been difficult to migrate existing Laravel projects to Hypervel because Hyperf's database packages are quite outdated. There are almost 100 missing methods, missing traits, it doesn't support nested transactions, there are old Laravel bugs which haven't been fixed (eg. JSON indices aren't handled correctly), coroutine safety issues (eg. model
unguard(),withoutTouching()). Other packages like pagination, collections and support are outdated too.Stringablewas missing a bunch of methods and traits, for example. There are just too many to PR to Hyperf at this point.2. Faster framework development
We need to be able to move quickly and waiting for Hyperf maintainers to merge things adds a lot of friction to framework development. Decoupling means we don't need to work around things like PHP 8.4 compatibility while waiting for it to be added upstream. Hyperf's testing package uses PHPUnit 10 so we can't update to PHPUnit 13 (and Pest 4 in the skeleton) when it releases in a couple of weeks. v13 has the fix that allows
RunTestsInCoroutineto work with newer PHPUnit versions. There are lots of examples like this.3. Parity with Laravel
We need to avoid the same drift from Laravel that's happened with Hyperf since 2019. If we're not proactive with regularly merging Laravel updates every week we'll end up in the same situation. Having a 1:1 directory and code structure to Laravel whenever possible will make this much easier. Especially when using AI tools.
Most importantly, we need to make it easier for Laravel developers to use and contribute to the framework. That means following the same APIs and directory structures and only modifying code when there's a good reason to (coroutine safety, performance, type modernisation etc).
Right now the Hypervel codebase is confusing for both Laravel developers and AI tools:
hypervel/contractspackage, the Hyperf database code is split across 3 packages, the Hyperf pagination package ishyperf/paginatorand nothyperf/pagination)static::registerCallback('creating')vsstatic::creating())ConfigProviderand LaravelServiceProviderpatterns across different packages is confusing for anyone who doesn't know HyperfThis makes it difficult for Laravel developers to port over apps and to contribute to the framework.
4. AI
The above issues mean that AI needs a lot of guidance to understand the Hypervel codebase and generate Hypervel boilerplate. A few examples:
hypervel/contractsfor contracts) and then have to spend a lot of time grepping for things to find them.And so on... This greatly limits the effectiveness of building Hypervel apps with AI. Unfortunately MCP docs servers and CLAUDE.md rules don't solve all these problems - LLMs aren't great at following instructions well and the sheer volume of Laravel data they've trained on means they always default to Laravel-style code. The only solution is 1:1 parity. Small improvements such as adding native type hints are fine - models can solve that kind of thing quickly from exception messages.
What changed so far
New packages
illuminate/databaseportilluminate/collectionsportilluminate/paginationportilluminate/contracts)hyperf/pool)Macroableto a separate package for Laravel parityRemoved Hyperf dependencies so far
Database package
The big task was porting the database package, making it coroutine safe, implementing performance improvements like static caching and modernising the types.
whereLike,whereNot,groupLimit,rawValue,soleValue, JSON operations, etc.Collections package
Contracts package
Support package
hyperf/tappable,hyperf/stringable,hyperf/macroable,hyperf/codecdependenciesStr,Envand helper classes from LaravelHypervel\Contextwrappers (will be portinghyperf/contextsoon)Number::useCurrency()wasn't actually setting the currency)Coroutine safety
withoutEvents(),withoutBroadcasting(),withoutTouching()now use Context instead of static propertiesUnsetContextInTaskWorkerListenerto clear database context in task workersConnection::resetForPool()to prevent state leaks between coroutinesDatabaseTransactionsManagercoroutine-safeBenefits
Testing status so far
What's left (WIP)
The refactor process
Hyperf's Swoole packages like
pool,coroutine,contextandhttp-serverhaven't changed in many years so porting these is straightforward. A lot of the code can be simplified since we don't need SWOW support. And we can still support the ecosystem by contributing any improvements we make back to Hyperf in separate PRs.Eventually I'll refactor the bigger pieces like the container (contextual binding would be nice!) and the config system (completely drop
ConfigProviderand move entirely to service providers). But those will be future PRs. For now the main refactors are the database layer, collections and support classes + the simple Hyperf packages. I'll just port the container and config packages as-is for now.Let me know if you have any feedback, questions or suggestions. I'm happy to make any changes you want. I suggest we just work through this gradually, as an ongoing task over the next month or so. I'll continue working in this branch and ping you each time I add something new.
New Container package
This is Swoole-optimised version of Laravel's IoC Container, replacing Hyperf's container. The goal: give Hypervel the complete Laravel container API while maintaining performance parity with Hyperf's container and full coroutine safety for Swoole's long-running process model.
Why replace Hyperf's container?
Hyperf's container is minimal. It exposes
get(),has(),make(),set(),unbind(), anddefine(). That's the entire public API. It has no support for:when()->needs()->give())resolving(),afterResolving(),beforeResolving())extend())tag(),tagged())scoped()) for request-lifecycle singletonsbindMethod(),call()with dependency injection)rebinding())bind()vssingleton())#[Config],#[Tag],#[CurrentUser], etc.)Also, the API is very different to Laravel's.
make()always returns fresh instances,get()doesn't respect the binding type etc.This makes it difficult to port Laravel code or use Laravel's service provider patterns without shimming everything. The new container closes that gap completely and makes interacting with the container much more familiar to Laravel devs. It also means that our package and test code will be closer to 1:1 with Laravel now.
API
The new container implements the full Laravel container contract:
bind($abstract, $concrete, $shared)bindIf(...)singleton($abstract, $concrete)singletonIf(...)scoped($abstract, $concrete)Context)scopedIf(...)instance($abstract, $instance)make($abstract, $parameters)get($id)build($concrete)call($callback, $parameters)when($concrete)->needs($abstract)->give($impl)extend($abstract, $closure)tag($abstracts, $tags)/tagged($tag)resolving()/afterResolving()/beforeResolving()afterResolvingAttribute($attribute, $callback)whenHasAttribute($attribute, $handler)alias($abstract, $alias)rebinding($abstract, $callback)factory($abstract)wrap($callback, $parameters)flush()forgetInstance($abstract)forgetInstances()forgetScopedInstances()It also supports closure return-type bindings (register a binding by returning a typed value from a closure, including union types),
SelfBuildinginterface for classes that control their own instantiation, andArrayAccessfor$container['key']syntax.Key API difference from Hyperf
Like Hyperf's
get(), unbound concrete classes are automatically cached as singletons on first resolution (critical for Swoole performance). However, unlike Hyperf (where the caching decision is at resolution time i.e.get()= cached,make()= fresh), our caching decision is at binding time:singleton()= cached,bind()= fresh, unbound concretes = auto-cached. This matches Laravel's API while preserving Hyperf's performance characteristics.Auto-singletoned instances are stored in a separate
$autoSingletonsarray (not$instances) so thatbound()doesn't report auto-cached classes as explicitly registered. This preserves correct behavior for optional typed constructor parameters with default values.Attribute-based injection
16 contextual attributes are included, providing declarative dependency injection:
#[Auth]#[Authenticated]#[Cache]#[Config('key')]#[Context('key')]#[CurrentUser]#[Database]#[DB]#[Give(value)]#[Log]#[RouteParameter('name')]#[Scoped]#[Singleton]#[Storage]#[Tag('name')]#[Bind(Concrete::class)]Example:
Performance
Build recipe caching
Constructor parameters are analyzed via reflection once per class and cached as
BuildRecipe/ParameterRecipevalue objects for the worker lifetime. Subsequent resolutions read from cached metadata with zero reflection overhead.Method parameter caching
Container::call()caches method parameter metadata the same way -BoundMethodmaintains a static$methodRecipescache keyed byClassName::methodName. Closures and global function strings fall back to per-call reflection since they lack a deterministic cache key.Reflection caching
ReflectionManagercachesReflectionClass,ReflectionMethod, andReflectionPropertyobjects statically for the worker lifetime, shared across the container and the rest of the framework.Hot-path optimizations
isset()on$instances→ return. No Context reads on the singleton path when no contextual bindings are registered.$scopedInstancesis a keyed array for O(1)isScoped()lookups (notin_array()).$extendersand resolving callback arrays are guarded byempty()checks before iteration.$this->contextualis guarded by!empty()before contextual binding lookup.Performance vs Hyperf
The singleton cache-hit path does marginally more work than Hyperf's single
isset()(we additionally check aliases, callbacks, and scoping), but the difference is nanoseconds per resolution. This would be undetectable in any real workload. For transient bindings (bind()classes resolved multiple times), warm resolutions are cheaper than Hyperf becauseBuildRecipecaching eliminates repeated reflection - Hyperf'sParameterResolverre-processesReflectionParameterobjects on everymake()call. Overall, real-workload performance is on par with Hyperf's container.Coroutine safety
All per-request state is stored in coroutine-local
Context, never in shared properties:Contextfinally)Contextfinally)Contextfinally)Contextfinally)Context$instances$autoSingletonsCircular dependency detection uses two complementary mechanisms:
A → B → C → A), stored in a dedicated Context key separate from the build stack to avoid false positives fromcall()All transient Context state is cleaned up in
finallyblocks, so exceptions during resolution never leak state to subsequent resolutions in the same coroutine.Scoped instance cleanup is handled consistently across all invalidation paths.
forgetInstance(),forgetInstances(),forgetScopedInstances(),dropStaleInstances()(triggered by rebinding),offsetUnset(), andflush()all destroy the corresponding Context entries. There's no path that clears a scoped binding's registration without also cleaning up its coroutine-local instance.Tests
~220 tests:
call()method, dependency injection, method recipe cachingwhen()->needs()->give(), variadic bindings, contextual primitivesReplace HTTP/Routing Stack with Symfony HttpFoundation + illuminate/routing
TL;DR
The entire HTTP and routing layer has been replaced. PSR-7, PSR-15, FastRoute, and the Hyperf DI coupling are gone, replaced by a 1:1 port of Laravel's
illuminate/routingandilluminate/httpon top of Symfony HttpFoundation, with Swoole-specific optimizations that make it faster than both the old Hypervel stack and stock Laravel. Routes compile once at server boot, static caches pre-warm before fork, workers inherit everything via copy-on-write, and the heaviest per-request costs (route object construction, regex compilation, Reflection) are eliminated entirely. The API now matches Laravel: same route registration, same middleware signatures, same request/response classes.Why This Refactor Was Needed
The old stack was a PSR-7/PSR-15/FastRoute hybrid with multiple adapter layers between Swoole and application code:
ResponsePlusProxy,ResponseProxyTrait, PSR-7 conversion at every boundary).handle($request, $next)). Keeping PSR-15 meant maintainingPsr15AdapterMiddleware,AdaptedRequestHandler, and dual-path pipeline logic for no Laravel alignment benefit.CompiledUrlMatcheris ~15x faster on real-world route sets.MethodDefinitionCollectorInterface,ClosureDefinitionCollectorInterface, and Hyperf's customReflectionType, none of which exist in Laravel. Every ported Laravel feature had to work around this.illuminate/routingon top of PSR-7 would have meant rewriting every class that touches request/response, creating massive upstream divergence and making future merges painful.Architecture Before vs After
Before
After
Package Changes
illuminate/httpServer.php+RequestBridge+ResponseBridge. Everything else deleted.illuminate/http. Request extendsSymfony\Component\HttpFoundation\Request. No Swoole-specific code.RouteCollector,DispatcherFactory, customUrlGeneratorilluminate/routing. ~45 files: Router, Route, CompiledRouteCollection, ControllerDispatcher, middleware resolution, URL generation, resource routes, model bindingRouting\Pipeline.1:1 Laravel API Parity
Developers can now use the same APIs they know from Laravel:
Route Registration
Middleware
Request & Response
What's Included
Hypervel\Http\Requestwith all traits (InteractsWithInput,InteractsWithContentTypes,InteractsWithFlashData,CanBePrecognitive)Response,JsonResponse,RedirectResponseextending Symfony base classesUploadedFilewithstore(),storeAs(),hashName()UrlGenerator,ResponseFactory,RedirectorControllerDispatcherwith implicit model binding and backed enum supportRouting,RouteMatched,PreparingResponse,ResponsePreparedroute:cache,route:clear,make:controller,make:middlewareTrustProxies,HandleCors,ValidatePostSize,SetCacheHeaders,ValidateSignature,SubstituteBindings,ThrottleRequestsPerformance: No More Proxy Layers
The old stack routed every request through multiple wrapper/adapter layers. Each layer added object allocation, method delegation, and type conversion overhead.
Before (5 objects, 3 conversions):
After (1 object each way, no conversion during request handling):
RequestBridge::createFromSwoole()constructs a singleHypervel\Http\Requestdirectly from Swoole's raw data. There are no intermediate PSR-7 objects, proxy traits, or conversion layers. The request object controllers receive is the same object created at the entry point.Performance: Compile Once, Pre-Warm, COW Fork
Swoole workers are long-lived, so everything that Laravel rebuilds per-request can become a one-time cost at server boot.
What Gets Cached at Server Boot (Static Properties)
CompiledRouteCollection::$compilednew CompiledUrlMatcher+new RequestContextevery requestCompiledRouteCollection::$cachedRoutesByNameRoute::$compiledbind()because freshly-constructed Route has null$compiledRoute::$computedMiddlewareReflectionMethodinstancesControllerDispatcher::$reflectionCachenew ReflectionMethod()per controller dispatchRouteSignatureParameters::$cachenew ReflectionMethodcalled twice per request (once for UrlRoutable, once for BackedEnum)class_implements/class_parentsresultsSortedMiddleware::$middlewareNamesCacheisEnumcheck resultsResolvesRouteDependencies::$enumCachenew ReflectionClass($className)->isEnum()per parameterReflectionFunctioninstancesCallableDispatcher::$reflectionCachenew ReflectionFunctionevery dispatchWhat Happens Per-Request
The remaining per-request work is lightweight:
isset($staticRoutes[$path])for static routes (O(1)), or onepreg_match()for dynamic routespreg_match()with pre-compiled route regexarray_reduceclosure stack building all run per-request. The expensive parts are mitigated:Route::$computedMiddlewarecaches the raw middleware list,SortedMiddlewarecachesclass_implements/class_parentslookups in statics, and the container resolves middleware instances as auto-singletons (hash table hits after first request).ReflectionMethod, cached signature parameters, cached enum checksprepare()+ event dispatch, matching Laravel's flowPre-Warming Before Fork
Caches are populated in the main process during
initCoreMiddleware(), before$server->start(). This runs viaRouter::compileAndWarm():Workers inherit the master process's memory via copy-on-write fork, so there's no additional cost per worker. When workers restart (via
max_request), new workers are forked from the master with all caches intact. First-request latency matches subsequent requests for all pre-warmed caches.Note on middleware pre-warming: Middleware class lists are only pre-warmed for controllers implementing the
HasMiddlewareinterface (the modern Laravel pattern, where middleware is declared via a static method). Controllers using the legacygetMiddleware()instance method can't be pre-warmed at boot because that path instantiates the controller, which requires a coroutine context that doesn't exist before fork. Their middleware lists are cached on first request instead.Symfony CompiledUrlMatcher vs FastRoute
isset($staticRoutes[$path]), O(1) hash lookup, no regex[^/]+(allows backtracking)[^/]++possessive quantifier (never backtracks)Route Caching
Full
route:cache/route:clearsupport, matching Laravel's implementation:php artisan route:cachepre-compiles routes tobootstrap/cache/routes-v7.phpviavar_export(). Closure routes are supported vialaravel/serializable-closure.php artisan route:cleardeletes the cache file.CachesRoutes, soServiceProvider::loadRoutesFrom()automatically skips route file loading when a cache exists.APP_ROUTES_CACHEenv var (absolute or relative path).Cost model:
route:cacheImpactvar_export()php artisan route:cacheinitCoreMiddleware()max_requesttriggerCoroutine Safety
All per-request mutable state uses coroutine
Context, not object properties on shared singletons or cached Route objects.What's Stored in Context (Per-Coroutine)
$id = 5)route.parametersroute.original_parametersroute.controller.{ClassName}router.currentrouter.currentRequestWhat's Safe to Cache on Route Objects (Immutable After Boot)
Route::$compiled: compiled regex, never changesRoute::$computedMiddleware: deterministic for a given route definitionRoute::$uri,$methods,$action,$wheres,$defaults: set at boot, never mutatedRequest Binding
Laravel does
$this->app->instance('request', $request)which writes to process-global$instances, a race condition under coroutines. The adaptation:All resolution paths (
app('request'),request()helper, DI type-hint) return the coroutine-local request.Correctness & Safety Improvements
Cache Flushing for Dev Reload
In Swoole's long-lived workers, stale static caches persist forever. Every cache class has a
flushCache()method, andRouter::setCompiledRoutes()/setRoutes()flush all 8 cache locations before replacing the route collection:CompiledRouteCollection::$cachedRoutesByNameControllerDispatcher::$reflectionCacheCallableDispatcher::$reflectionCacheRouteSignatureParameters::$cacheSortedMiddleware::$middlewareNamesCacheImplicitRouteBinding::$signatureCacheControllerDispatcher::$enumCache(trait static, separate per-using class)CallableDispatcher::$enumCacheFixed Callable Dispatch for All Callable Shapes
The old
CallableDispatcher::resolveParameters()usednew ReflectionFunction($callable)unconditionally, which fails for array callables ([ClassName::class, 'method']) that can reach this path viaRouteAction::findCallable(). This is a pre-existing bug in Laravel too. The new implementation handles all callable shapes:Closures get cached by
spl_object_id()(stable for the worker lifetime). Other callable shapes are rare enough that caching isn't worthwhile.Safe Streaming Response Handling
Two streaming paths are supported, with double-send prevention:
Direct Swoole path (
Response::stream()/streamDownload()): writes chunks directly to the Swoole socket viaWritableConnection. Marks the response as streamed (isStreamed()) immediately after sending headers, before invoking the callback. Even if the callback throws after partial writes, the bridge knows not to re-send.Symfony StreamedResponse (echo-based):
ResponseBridgeinterceptsechooutput viaob_startwith a callback wired to$swooleResponse->write(). OB level is safely restored in atry/finallyeven ifsendContent()throws, preventing OB level leaks across requests in long-lived workers.ResponseBridge::send()checks$response->isStreamed()first. If the direct path already sent the response, it only calls$swooleResponse->end()to finalize the chunked transfer.WebSocket Handshake Unification
The WebSocket server's handshake now routes through the same Router and middleware pipeline as HTTP requests. Previously,
WebSocketServer\Serverhad its ownCoreMiddleware(extending the now-deletedHttpServer\CoreMiddleware), its ownMiddlewareManagerusage, and its ownDispatchedresult handling.WebSocketServer\Server::initCoreMiddleware()now calls the sameRouter::compileAndWarm(), so WS handshake routes benefit from the same compiled matching, pre-warmed caches, and middleware resolution as HTTP routes. In combined HTTP+WS setups,compileAndWarm()is idempotent: whoever initializes first does the work, the second call is a no-op.Kernel / Server Separation
The HTTP application layer and the Swoole transport layer are now separate:
HttpServer\ServerRequestBridge/ResponseBridgeconversion, lifecycle events (Telescope),initCoreMiddleware()boot sequence. ImplementsOnRequestInterface.Foundation\Http\KernelRouter::dispatch(), response preparation. No Swoole dependencies. ImplementsContracts\Http\Kernel.App\Http\KernelFoundation\Http\Kernel. Defines$middleware,$middlewareGroups,$middlewareAliases,$middlewarePriority. Same structure as Laravel.The adapter resolves the Kernel via the
Contracts\Http\Kernelcontract binding (set inbootstrap/app.php), the same pattern Laravel uses. The Kernel takes anHypervel\Http\Requestand returns aSymfony\Component\HttpFoundation\Response. It has no Swoole dependencies.Breaking Changes & Migration
Middleware Signature
Request/Response Types
HTTP Exceptions
Route Registration
Config / Kernel Binding
Deleted Packages & Classes
http-message: entire package (PSR-7 implementation, streams, URI, cookies, HTTP exceptions)dispatcher:HttpDispatcher,Psr15AdapterMiddleware,AdaptedRequestHandler,PipelineRouteCollector,DispatcherFactory,Dispatched,Handler,RouteFileCollectorCoreMiddleware,MiddlewareManager,MiddlewareExclusionManager,PriorityMiddleware, PSR-7Request/Responsewrappers,ResponseEmitterCoreMiddleware,DispatchedRoute,RouteDependency,AcceptHeader,HeaderUtils,CorsMessagePlusInterface,RequestPlusInterface,ResponsePlusInterface,ServerRequestPlusInterface,UriPlusInterface),Http\Request,Http\ResponseSummary
Route::bind()recompiles)ReflectionMethodper requestNew Context package
1. Propagated Context
Why this feature matters
Laravel 11 introduced
Illuminate\Log\Context\Repository- an API for storing per-request metadata that automatically flows into log entries and queued job payloads.This is genuinely useful beyond just Laravel parity. Two concrete problems it solves:
extrafield of every log entry for that request. No need to pass context arrays to everyLog::info()call or maintain custom log processors per app.Both of these work transparently once configured - add context once at the start of a request, and it flows everywhere it needs to go.
Why extend Context instead of porting Laravel's Context classes
Hypervel's
Contextand Laravel'sContextare fundamentally different things:ContextContextRepositoryobjectPorting Laravel's
Illuminate\Log\Contextclasses directly would create confusing overlap - two "context" classes doing different things. Instead, I extendedContextwith apropagated()method that returns aPropagatedContextinstance:Context) for all per-request stateset()/get()for raw coroutine storage,propagated()->add()/get()for metadata that flows to logs/jobsPropagatedContextmethods map directly to Laravel'sRepositorymethodsPropagatedContextinstance is stored inside coroutine context under a reserved keyWhy
propagated()instead of flat static methodsI considered adding static methods like
Context::getPropagated(),Context::forgetPropagated(),Context::getPropagatedHidden()etc. directly onContext. Rejected because:Context, most with verbose names (forgetPropagatedHidden,pullPropagatedHidden)Context::propagated()returning thePropagatedContextinstance is cleaner - one entry point, then use Laravel-equivalent method names (add,get,forget,addHidden, etc.)$propagated = Context::propagated();Context::facade methods, making migration documentation straightforwardArchitecture
The
PropagatedContextinstance lives under the coroutine context key__context.propagated, created lazily on first access:Context::hasPropagated()checks if aPropagatedContextinstance exists without creating one. Used in the log processor and queue payload hook to avoid allocating an empty instance on every request when propagated context is never used.How it works: log integration
ContextLogProcessoris pushed onto every resolved log channel. It adds propagated context data to the log record'sextrafield:Key details:
$data) flows to logs. Hidden data ($hidden) is intentionally excluded from log records - it only propagates to jobs.hasPropagated()guard means zero overhead on requests that never use propagated context.LogManager::get()for named channels andLogManager::stack()for on-demand stacks.createStackDriver()filters outContextLogProcessorfrom constituent channel processors to prevent duplication when channels are combined into stacks.How it works: queue integration
Wired in
QueueServiceProvider::boot():Key details:
dehydrate()works on a clone so dehydrating callbacks can modify values without affecting the live propagated context.dehydrate()returnsnullwhen propagated context is empty - no payload bloat for jobs that don't use context.hydrate()flushes existing propagated context before restoring, dispatchesContextHydratedso service providers can react.JobProcessed/JobExceptionOccurredis needed. Async jobs run in their own coroutine - Swoole destroys the coroutine context when it ends. Sync jobs run in the request's coroutine, which is expected (same as Laravel).ContextDehydratingandContextHydratedevents allow service providers to hook into the serialization lifecycle.How it works: coroutine isolation
When coroutines are forked via
Context::copy(), thePropagatedContextinstance is replicated so the child gets its own independent copy rather than sharing an object reference with the parent. This ensures mutations in a child coroutine don't affect the parent's propagated context:The same replication is applied in
Context::copyFromNonCoroutine().Usage examples
Laravel equivalence mapping
Full API parity with Laravel's
Illuminate\Log\Context\Repository:Context::add($key, $value)Context::propagated()->add($key, $value)addIf($key, $value)Context::propagated()->addIf($key, $value)get($key)Context::propagated()->get($key)has($key)Context::propagated()->has($key)missing($key)Context::propagated()->missing($key)all()Context::propagated()->all()only($keys)Context::propagated()->only($keys)except($keys)Context::propagated()->except($keys)forget($key)Context::propagated()->forget($key)pull($key)Context::propagated()->pull($key)remember($key, $value)Context::propagated()->remember($key, $value)push($key, ...$values)Context::propagated()->push($key, ...$values)pop($key)Context::propagated()->pop($key)stackContains($key, $value)Context::propagated()->stackContains($key, $value)increment($key, $amount)Context::propagated()->increment($key, $amount)decrement($key, $amount)Context::propagated()->decrement($key, $amount)addHidden($key, $value)Context::propagated()->addHidden($key, $value)addHiddenIf($key, $value)Context::propagated()->addHiddenIf($key, $value)getHidden($key)Context::propagated()->getHidden($key)hasHidden($key)Context::propagated()->hasHidden($key)missingHidden($key)Context::propagated()->missingHidden($key)allHidden()Context::propagated()->allHidden()onlyHidden($keys)Context::propagated()->onlyHidden($keys)exceptHidden($keys)Context::propagated()->exceptHidden($keys)forgetHidden($key)Context::propagated()->forgetHidden($key)pullHidden($key)Context::propagated()->pullHidden($key)rememberHidden($key, $value)Context::propagated()->rememberHidden($key, $value)pushHidden($key, ...$values)Context::propagated()->pushHidden($key, ...$values)popHidden($key)Context::propagated()->popHidden($key)hiddenStackContains($key, $value)Context::propagated()->hiddenStackContains($key, $value)scope($callback, $data, $hidden)Context::propagated()->scope($callback, $data, $hidden)isEmpty()Context::propagated()->isEmpty()flush()Context::propagated()->flush()dehydrating($callback)Context::propagated()->dehydrating($callback)hydrated($callback)Context::propagated()->hydrated($callback)dehydrate()Context::propagated()->dehydrate()hydrate($context)Context::propagated()->hydrate($context)handleUnserializeExceptionsUsing($cb)Context::propagated()->handleUnserializeExceptionsUsing($cb)New files
src/context/src/PropagatedContext.phpsrc/context/src/Events/ContextDehydrating.phpsrc/context/src/Events/ContextHydrated.phpsrc/log/src/ContextLogProcessor.phpModified files
src/context/src/Context.phppropagated(),hasPropagated(),PROPAGATED_CONTEXT_KEYconstant,replicate()calls incopy()andcopyFromNonCoroutine()src/context/src/helpers.phpcontext()docblock to referenceContext::propagated()src/context/composer.jsonhypervel/events,hypervel/queue,hypervel/conditionable,hypervel/macroable,hypervel/supportsrc/contracts/src/Queue/Job.phppayload()method to the Job contractsrc/log/src/LogManager.phpContextLogProcessoronto resolved channels and on-demand stacks, filters duplicates in stack driversrc/queue/src/QueueServiceProvider.phpQueue::createPayloadUsingfor dehydration andJobProcessinglistener for hydrationTest coverage
tests/Context/PropagatedContextTest.phpPropagatedContextmethods - data ops, hidden ops, stacks, counters, scope, flush, dehydrate/hydrate, event hooks, error handlingtests/Context/ContextPropagatedTest.phpContext::propagated()- lazy creation, same-instance identity, isolation from raw context,hasPropagated()guard,flush()clearingtests/Context/PropagatedContextCoroutineTest.phptests/Context/PropagatedContextLogTest.phptests/Context/PropagatedContextQueueTest.phptests/Integration/Context/PropagatedContextIntegrationTest.php2. Method name alignment:
destroy->forget,destroyAll->flushRenamed
Context::destroy()toContext::forget()andContext::destroyAll()toContext::flush(). Aligns with Laravel's naming conventions (forgetused throughout Collections, Session, Cache, etc.) and withPropagatedContextwhich also usesforget()andflush().Hard break, no deprecation - pre-1.0 and all call sites are ours. ~50 files across
src/andtests/.The same rename was applied to the context subclasses:
ParentContext::forget(),RequestContext::forget(),ResponseContext::forget(), andWebSocketServer\Context::forget().