Skip to content

feat: framework refactor + decouple from Hyperf#349

Merged
binaryfire merged 3804 commits intohypervel:0.4from
binaryfire:feature/hyperf-decouple
Apr 3, 2026
Merged

feat: framework refactor + decouple from Hyperf#349
binaryfire merged 3804 commits intohypervel:0.4from
binaryfire:feature/hyperf-decouple

Conversation

@binaryfire
Copy link
Copy Markdown
Collaborator

@binaryfire binaryfire commented Jan 26, 2026

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. Stringable was 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 RunTestsInCoroutine to 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:

  • Some classes use Hyperf classes directly, some extend them, some replace them. You need to check multiple places to see what methods are available
  • Some Hyperf methods have old (2019) Laravel signatures while some overridden ones have new ones
  • The classes are in different locations to Laravel (eg. there's no hypervel/contracts package, the Hyperf database code is split across 3 packages, the Hyperf pagination package is hyperf/paginator and not hyperf/pagination)
  • The tests dir structure and class names are different, making it hard to know what tests are missing when comparing them to Laravel's tests dir
  • There are big differences in the API (eg. static::registerCallback('creating') vs static::creating())
  • The mix of Hyperf ConfigProvider and Laravel ServiceProvider patterns across different packages is confusing for anyone who doesn't know Hyperf
  • There are big functional differences eg. no nested database transactions

This 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:

  • Models have trained extensively on Laravel code and expect things to have the same API. Generated boilerplate almost always contains incompatible Laravel-style code which means you have to constantly interrupt and guide them to the Hypervel-specific solutions.
  • Models get confused when they have to check both Hypervel and Hyperf dependencies. They start by searching for files in the same locations as Laravel (eg. hypervel/contracts for contracts) and then have to spend a lot of time grepping for things to find them.
  • The inheritance chain causes major problems. Models often search Hypervel classes for methods and won't remember to search the parent Hyperf classes as well.

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

Package Purpose
hypervel/database Full illuminate/database port
hypervel/collections Full illuminate/collections port
hypervel/pagination Full illuminate/pagination port
hypervel/contracts Centralised cross-cutting contracts (same as illuminate/contracts)
hypervel/pool Connection pooling (internalised from hyperf/pool)
hypervel/macroable Moved Macroable to a separate package for Laravel parity

Removed Hyperf dependencies so far

  • hyperf/database
  • hyperf/database-pgsql
  • hyperf/database-sqlite
  • hyperf/db-connection
  • hyperf/collection
  • hyperf/stringable
  • hyperf/tappable
  • hyperf/macroable
  • hyperf/codec

Database package

The big task was porting the database package, making it coroutine safe, implementing performance improvements like static caching and modernising the types.

  • Ported from Laravel 12
  • Added 50+ missing methods: whereLike, whereNot, groupLimit, rawValue, soleValue, JSON operations, etc.
  • Full Schema builder parity including schema states
  • Complete migration system (commands are still using Hyperf generators for now)
  • Both Hyperf and Hypervel previously used global PHPStan ignores on database classes. I removed these and fixed the underlying issues - only legitimate "magic" things are ignored now.

Collections package

  • Full Laravel parity across all methods
  • Modernised with PHP 8.2+ native types
  • Full LazyCollection support
  • Proper generics for static analysis

Contracts package

  • Centralised cross-cutting interfaces (like illuminate/contracts)
  • Clean dependency boundaries - packages depend on contracts, not implementations
  • Enables proper dependency inversion across the framework

Support package

  • Removed hyperf/tappable, hyperf/stringable, hyperf/macroable, hyperf/codec dependencies
  • Freshly ported Str, Env and helper classes from Laravel
  • Consistent use of Hypervel\Context wrappers (will be porting hyperf/context soon)
  • Fixed some existing bugs (eg. Number::useCurrency() wasn't actually setting the currency)

Coroutine safety

  • withoutEvents(), withoutBroadcasting(), withoutTouching() now use Context instead of static properties
  • Added UnsetContextInTaskWorkerListener to clear database context in task workers
  • Added Connection::resetForPool() to prevent state leaks between coroutines
  • Made DatabaseTransactionsManager coroutine-safe
  • Comprehensive tests verify isolation

Benefits

  1. Laravel 1:1 API parity - code from Laravel docs works directly
  2. AI compatibility - AI assistants generate working Hypervel code
  3. Easier contributions - Laravel devs can contribute without learning Hyperf
  4. Reduced maintenance - fewer external dependencies to track and update
  5. Modern PHP - native types throughout, PHP 8.2+ patterns
  6. Better static analysis - proper generics and type hints
  7. Coroutine safety - verified isolation with comprehensive tests

Testing status so far

  • PHPStan passes at level 5
  • All existing tests pass
  • Coroutine isolation tests verify safety

What's left (WIP)

  • Port the remaining Hyperf dependencies
  • Port the full Laravel and Hyperf test suites
  • Documentation updates
  • Your feedback on architectural decisions

The refactor process

Hyperf's Swoole packages like pool, coroutine, context and http-server haven'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 ConfigProvider and 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(), and define(). That's the entire public API. It has no support for:

  • Contextual bindings (when()->needs()->give())
  • Resolving callbacks (resolving(), afterResolving(), beforeResolving())
  • Service extenders (extend())
  • Tagging (tag(), tagged())
  • Scoped bindings (scoped()) for request-lifecycle singletons
  • Method bindings (bindMethod(), call() with dependency injection)
  • Rebound callbacks (rebinding())
  • Binding-time singleton/transient control (bind() vs singleton())
  • Attribute-based contextual injection (#[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:

Method Description
bind($abstract, $concrete, $shared) Register a binding (fresh instance each resolution)
bindIf(...) Register only if not already bound
singleton($abstract, $concrete) Register a shared binding (cached for worker lifetime)
singletonIf(...) Register only if not already bound
scoped($abstract, $concrete) Register a request-scoped singleton (cached in coroutine-local Context)
scopedIf(...) Register only if not already bound
instance($abstract, $instance) Register a pre-resolved instance
make($abstract, $parameters) Resolve from the container (respects binding type)
get($id) PSR-11 compliant resolution
build($concrete) Always build a fresh instance, bypassing bindings
call($callback, $parameters) Call a method/closure with dependency injection
when($concrete)->needs($abstract)->give($impl) Contextual bindings
extend($abstract, $closure) Decorate or modify resolved instances
tag($abstracts, $tags) / tagged($tag) Group bindings by tag
resolving() / afterResolving() / beforeResolving() Resolution lifecycle hooks
afterResolvingAttribute($attribute, $callback) Attribute-based lifecycle hooks
whenHasAttribute($attribute, $handler) Attribute-based contextual injection
alias($abstract, $alias) Type aliasing
rebinding($abstract, $callback) Rebound event hooks
factory($abstract) Get a closure that resolves the type
wrap($callback, $parameters) Wrap a closure for deferred dependency injection
flush() Clear all bindings, instances, and caches
forgetInstance($abstract) Remove a single cached instance
forgetInstances() Remove all cached instances
forgetScopedInstances() Remove all scoped instances (request cleanup)

It also supports closure return-type bindings (register a binding by returning a typed value from a closure, including union types), SelfBuilding interface for classes that control their own instantiation, and ArrayAccess for $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 $autoSingletons array (not $instances) so that bound() 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:

Attribute Resolves
#[Auth] Auth guard instance
#[Authenticated] Currently authenticated user (or throws)
#[Cache] Cache store instance
#[Config('key')] Configuration value
#[Context('key')] Coroutine context value
#[CurrentUser] Current user (nullable)
#[Database] Database connection
#[DB] Database connection (alias)
#[Give(value)] Inline contextual value
#[Log] Logger channel
#[RouteParameter('name')] Route parameter value
#[Scoped] Mark class as request-scoped singleton
#[Singleton] Mark class as process-global singleton
#[Storage] Filesystem disk instance
#[Tag('name')] Resolve all tagged bindings
#[Bind(Concrete::class)] Attribute-based interface binding (with environment support)

Example:

class OrderService
{
    public function __construct(
        #[Config('orders.tax_rate')] private float $taxRate,
        #[Tag('payment-processors')] private array $processors,
        #[Authenticated] private User $user,
    ) {}
}

Performance

Build recipe caching

Constructor parameters are analyzed via reflection once per class and cached as BuildRecipe / ParameterRecipe value objects for the worker lifetime. Subsequent resolutions read from cached metadata with zero reflection overhead.

First resolution:  ReflectionClass → ReflectionMethod → ReflectionParameter[] → BuildRecipe (cached)
Subsequent:        BuildRecipe → resolve parameters → instantiate (no reflection)

Method parameter caching

Container::call() caches method parameter metadata the same way - BoundMethod maintains a static $methodRecipes cache keyed by ClassName::methodName. Closures and global function strings fall back to per-call reflection since they lack a deterministic cache key.

Reflection caching

ReflectionManager caches ReflectionClass, ReflectionMethod, and ReflectionProperty objects statically for the worker lifetime, shared across the container and the rest of the framework.

Hot-path optimizations

  • Singleton cache hit path: alias resolution → empty-check early exits for callbacks → isset() on $instances → return. No Context reads on the singleton path when no contextual bindings are registered.
  • $scopedInstances is a keyed array for O(1) isScoped() lookups (not in_array()).
  • $extenders and resolving callback arrays are guarded by empty() checks before iteration.
  • $this->contextual is 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 because BuildRecipe caching eliminates repeated reflection - Hyperf's ParameterResolver re-processes ReflectionParameter objects on every make() 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:

State Storage Lifetime
Build stack Context Transient (during resolution, cleaned in finally)
Resolving stack Context Transient (during resolution, cleaned in finally)
Parameter overrides Context Transient (during resolution, cleaned in finally)
Resolution depth Context Transient (during resolution, cleaned in finally)
Scoped instances Context Request lifetime (cleaned on coroutine exit)
Singletons $instances Worker lifetime (process-global, by design)
Auto-singletons $autoSingletons Worker lifetime (process-global, by design)
Bindings, aliases, tags, etc. Properties Worker lifetime (process-global, by design)

Circular dependency detection uses two complementary mechanisms:

  1. Resolving stack - direct cycle detection with full dependency chain reporting (e.g., A → B → C → A), stored in a dedicated Context key separate from the build stack to avoid false positives from call()
  2. Depth guard - safety net for indirect cycles through aliases/interfaces where abstract names differ, capped at 500 levels

All transient Context state is cleaned up in finally blocks, 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(), and flush() 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:

  • ContainerTest - core API (binding, resolution, singleton, scoped, aliases, flush, attributes)
  • ContainerCallTest - call() method, dependency injection, method recipe caching
  • ContextualBindingTest - when()->needs()->give(), variadic bindings, contextual primitives
  • ContainerExtendTest - service extenders, scoped extend behavior
  • ContainerTaggingTest - tagging and tagged resolution
  • ResolvingCallbackTest - before/resolving/after callbacks, global and typed
  • AfterResolvingAttributeCallbackTest - attribute-based lifecycle callbacks
  • ContainerResolveNonInstantiableTest - error messages for interfaces, abstract classes
  • CoroutineSafetyTest - coroutine isolation for scoped instances, build stack, parameter overrides, exception cleanup
  • ReflectionManagerTest - reflection caching
  • RewindableGeneratorTest - lazy tagged resolution
  • UtilTest - utility helpers

Replace 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/routing and illuminate/http on 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:

  • A Swoole request passed through a PSR-7 wrapper, then a request proxy, then an HttpServer request, then an Http request, each layer adding method delegation overhead. Responses had the same problem (ResponsePlusProxy, ResponseProxyTrait, PSR-7 conversion at every boundary).
  • Laravel uses closure-based middleware (handle($request, $next)). Keeping PSR-15 meant maintaining Psr15AdapterMiddleware, AdaptedRequestHandler, and dual-path pipeline logic for no Laravel alignment benefit.
  • FastRoute did linear O(n) route scanning with 4 validators per route in dev mode, and chunked regex with dummy capture groups in production. Symfony's CompiledUrlMatcher is ~15x faster on real-world route sets.
  • Controller parameter resolution used MethodDefinitionCollectorInterface, ClosureDefinitionCollectorInterface, and Hyperf's custom ReflectionType, none of which exist in Laravel. Every ported Laravel feature had to work around this.
  • Porting illuminate/routing on top of PSR-7 would have meant rewriting every class that touches request/response, creating massive upstream divergence and making future merges painful.
  • Route registration, middleware signatures, and request/response classes were all different from Laravel. Developers had to learn Hypervel-specific APIs instead of using the Laravel knowledge they already had.

Architecture Before vs After

Before

Swoole\Http\Request
  → HttpMessage\Server\Request::loadFromSwooleRequest()    PSR-7 wrapper
  → Foundation\Http\Kernel::onRequest()                    Kernel extends HttpServer\Server
    → Http\CoreMiddleware::dispatch()                      FastRoute dispatcher
    → HttpServer\Router\Dispatched                         FastRoute result wrapper
    → Dispatcher\HttpDispatcher                             PSR-15 middleware pipeline
      → Dispatcher\Psr15AdapterMiddleware                  PSR-15 ↔ closure adapter
    → Http\CoreMiddleware::process()                       Hyperf\Di controller resolution
    → ResponseEmitter::emit()                              PSR-7 → Swoole conversion

After

Swoole\Http\Request
  → HttpServer\Server::onRequest()                         Thin Swoole adapter
    → RequestBridge::createFromSwoole()                    Creates HttpFoundation request (one object, no proxying)
    → Kernel::handle($request)                             Laravel-style kernel (no Swoole dependencies)
      → Global middleware Pipeline                         TrustProxies, ValidatePostSize, etc.
      → Router::dispatch($request)                         Symfony CompiledUrlMatcher
        → Route (cached, immutable)                        Laravel Route object
        → Route middleware Pipeline                        auth, throttle, etc. (closure-based)
        → ControllerDispatcher::dispatch()                 Native PHP Reflection (cached)
    → ResponseBridge::send()                               HttpFoundation → Swoole
    → Kernel::terminate()                                  Terminable middleware

Package Changes

Before After
http-message: Full PSR-7 implementation (streams, URI, cookies, upload, HTTP exceptions) Deleted entirely. Replaced by Symfony HttpFoundation + ported illuminate/http
http-server: Swoole server + FastRoute router + CoreMiddleware + MiddlewareManager + PSR-7 request/response wrappers + ResponseEmitter Slimmed to thin Swoole adapter. Just Server.php + RequestBridge + ResponseBridge. Everything else deleted.
http: Custom Request/Response extending PSR-7, CoreMiddleware, RouteDependency, AcceptHeader, HeaderUtils 1:1 port of illuminate/http. Request extends Symfony\Component\HttpFoundation\Request. No Swoole-specific code.
router: FastRoute RouteCollector, DispatcherFactory, custom UrlGenerator Full port of illuminate/routing. ~45 files: Router, Route, CompiledRouteCollection, ControllerDispatcher, middleware resolution, URL generation, resource routes, model binding
dispatcher: HttpDispatcher, PSR-15 adapters, Pipeline Deleted (HTTP concerns). Replaced by Routing\Pipeline.

1:1 Laravel API Parity

Developers can now use the same APIs they know from Laravel:

Route Registration

// Fluent route registration, same as Laravel
Route::get('/users', [UserController::class, 'index']);
Route::post('/users', [UserController::class, 'store'])->middleware('auth');

Route::middleware(['auth', 'admin'])->prefix('/admin')->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'index']);
});

// Resource routes, singleton resources, route model binding, signed URLs,
// whereNumber()/whereAlpha() constraints, etc.
Route::resource('photos', PhotoController::class);
Route::get('/users/{user}', [UserController::class, 'show']);  // implicit model binding

Middleware

// Closure middleware (replaces PSR-15)
use Closure;
use Hypervel\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class MyMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        return $next($request);
    }
}

// Controller middleware via HasMiddleware interface
class UserController extends Controller implements HasMiddleware
{
    public static function middleware(): array
    {
        return [
            new Middleware('auth'),
            new Middleware('log', except: ['index']),
        ];
    }
}

Request & Response

// Full Laravel Request API (extends Symfony HttpFoundation)
$request->input('name');
$request->validate(['email' => 'required|email']);
$request->user();
$request->bearerToken();
$request->wantsJson();
$request->file('avatar');
$request->merge(['processed' => true]);

// Symfony HTTP exceptions
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

What's Included

  • Hypervel\Http\Request with all traits (InteractsWithInput, InteractsWithContentTypes, InteractsWithFlashData, CanBePrecognitive)
  • Response, JsonResponse, RedirectResponse extending Symfony base classes
  • UploadedFile with store(), storeAs(), hashName()
  • UrlGenerator, ResponseFactory, Redirector
  • ControllerDispatcher with implicit model binding and backed enum support
  • Routing events: Routing, RouteMatched, PreparingResponse, ResponsePrepared
  • Artisan commands: route:cache, route:clear, make:controller, make:middleware
  • HTTP middleware: TrustProxies, HandleCors, ValidatePostSize, SetCacheHeaders, ValidateSignature, SubstituteBindings, ThrottleRequests

Performance: 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):

Swoole Request → PSR-7 ServerRequest → RequestProxy → HttpServer\Request → Http\Request
                                                                                ↓
Swoole Response ← ResponseEmitter ← PSR-7 Response ← ResponseProxy ← Http\Response

After (1 object each way, no conversion during request handling):

Swoole Request → RequestBridge → Hypervel\Http\Request (extends Symfony HttpFoundation)
                                                         ↓
Swoole Response ← ResponseBridge ←───────── Hypervel\Http\Response

RequestBridge::createFromSwoole() constructs a single Hypervel\Http\Request directly 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)

Data Cache Location Per-Request in Laravel?
Compiled URL matcher (static hash map + combined regex) CompiledRouteCollection::$compiled Yes: new CompiledUrlMatcher + new RequestContext every request
Route objects by name CompiledRouteCollection::$cachedRoutesByName Yes: reconstructed from attributes array every request
Compiled regex per Route Route::$compiled Yes: recompiled during bind() because freshly-constructed Route has null $compiled
Resolved middleware class lists Route::$computedMiddleware Yes: Collection pipeline + Reflection + group expansion + exclusion filtering
Controller ReflectionMethod instances ControllerDispatcher::$reflectionCache Yes: new ReflectionMethod() per controller dispatch
Route signature parameters RouteSignatureParameters::$cache Yes: new ReflectionMethod called twice per request (once for UrlRoutable, once for BackedEnum)
class_implements / class_parents results SortedMiddleware::$middlewareNamesCache Yes: Reflection per middleware for priority sorting
isEnum check results ResolvesRouteDependencies::$enumCache Yes: new ReflectionClass($className)->isEnum() per parameter
Closure ReflectionFunction instances CallableDispatcher::$reflectionCache Yes: new ReflectionFunction every dispatch

What Happens Per-Request

The remaining per-request work is lightweight:

  1. URL matching: isset($staticRoutes[$path]) for static routes (O(1)), or one preg_match() for dynamic routes
  2. Parameter binding: one preg_match() with pre-compiled route regex
  3. Middleware pipeline construction: group expansion, exclusion filtering, priority sorting, and array_reduce closure stack building all run per-request. The expensive parts are mitigated: Route::$computedMiddleware caches the raw middleware list, SortedMiddleware caches class_implements/class_parents lookups in statics, and the container resolves middleware instances as auto-singletons (hash table hits after first request).
  4. Controller dispatch: uses cached ReflectionMethod, cached signature parameters, cached enum checks
  5. Response preparation: type conversion + prepare() + event dispatch, matching Laravel's flow

Pre-Warming Before Fork

Caches are populated in the main process during initCoreMiddleware(), before $server->start(). This runs via Router::compileAndWarm():

HttpServer\Server::initCoreMiddleware()          ← Main process, pre-fork
  → Kernel::bootstrap()                          ← Boot all providers, load routes
  → Router::compileAndWarm()
    → RouteCollection::compile()                 ← Dev mode: build CompiledRouteCollection
    → warmUp()                                   ← Pre-warm static caches
      → Route::ensureCompiled()                  ← Every route
      → Route::gatherMiddleware()                ← Controllers implementing HasMiddleware
      → RouteSignatureParameters::fromAction()   ← Controller routes
      → ControllerDispatcher::warmReflection()   ← Controller routes
$server->start()                                 ← Fork workers, inherit caches via COW

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 HasMiddleware interface (the modern Laravel pattern, where middleware is declared via a static method). Controllers using the legacy getMiddleware() 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

Technique FastRoute Symfony CompiledUrlMatcher
Static routes Checked via regex (no special path) isset($staticRoutes[$path]), O(1) hash lookup, no regex
Dynamic route regex Chunked into ~10 routes per regex, dummy capture groups to disambiguate Single combined regex with PCRE MARK verbs, prefix tree structure
Backtracking [^/]+ (allows backtracking) [^/]++ possessive quantifier (never backtracks)
Branch structure Flat alternation, PCRE tries each branch sequentially Prefix tree, entire subtrees skipped on prefix mismatch
Benchmark Baseline ~15x faster on 400-route benchmarks (Nicolas Grekas)

Route Caching

Full route:cache / route:clear support, matching Laravel's implementation:

  • php artisan route:cache pre-compiles routes to bootstrap/cache/routes-v7.php via var_export(). Closure routes are supported via laravel/serializable-closure.
  • php artisan route:clear deletes the cache file.
  • Application implements CachesRoutes, so ServiceProvider::loadRoutesFrom() automatically skips route file loading when a cache exists.
  • Cache path is configurable via APP_ROUTES_CACHE env var (absolute or relative path).

Cost model:

Cost Tier What When route:cache Impact
Deploy time Route file parsing, registration, Symfony compilation, var_export() php artisan route:cache Moves offline entirely
Boot (once, master) Load cached routes, construct Route objects, compile regex, resolve middleware, cache Reflection initCoreMiddleware() Skips file loading + compilation; still does object construction + warming
Worker restart Nothing, inherits master state via COW fork max_request trigger No impact
Per-request URL matching, parameter binding, controller dispatch Every request No impact

Coroutine 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)

State Why It Can't Be On the Object Context Key
Route parameters ($id = 5) Cached Route shared across all coroutines route.parameters
Original parameters (pre-substitution) Same route.original_parameters
Controller instance May hold per-request state (injected Request, etc.) route.controller.{ClassName}
Current route Router is a singleton router.current
Current request Router is a singleton router.currentRequest

What's Safe to Cache on Route Objects (Immutable After Boot)

  • Route::$compiled: compiled regex, never changes
  • Route::$computedMiddleware: deterministic for a given route definition
  • Route::$uri, $methods, $action, $wheres, $defaults: set at boot, never mutated

Request Binding

Laravel does $this->app->instance('request', $request) which writes to process-global $instances, a race condition under coroutines. The adaptation:

// Adapter stores request in coroutine Context
RequestContext::set($request);

// HttpServiceProvider binds via factory, every resolution reads from Context
$this->app->bind('request', fn () => RequestContext::get());

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, and Router::setCompiledRoutes() / setRoutes() flush all 8 cache locations before replacing the route collection:

  • CompiledRouteCollection::$cachedRoutesByName
  • ControllerDispatcher::$reflectionCache
  • CallableDispatcher::$reflectionCache
  • RouteSignatureParameters::$cache
  • SortedMiddleware::$middlewareNamesCache
  • ImplicitRouteBinding::$signatureCache
  • ControllerDispatcher::$enumCache (trait static, separate per-using class)
  • CallableDispatcher::$enumCache

Fixed Callable Dispatch for All Callable Shapes

The old CallableDispatcher::resolveParameters() used new ReflectionFunction($callable) unconditionally, which fails for array callables ([ClassName::class, 'method']) that can reach this path via RouteAction::findCallable(). This is a pre-existing bug in Laravel too. The new implementation handles all callable shapes:

if ($callable instanceof Closure) {
    $reflector = static::$reflectionCache[spl_object_id($callable)]
        ??= new ReflectionFunction($callable);
} elseif (is_array($callable)) {
    $reflector = new ReflectionMethod($callable[0], $callable[1]);
} elseif (is_object($callable)) {
    $reflector = new ReflectionMethod($callable, '__invoke');
} else {
    $reflector = new ReflectionFunction($callable);
}

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:

  1. Direct Swoole path (Response::stream() / streamDownload()): writes chunks directly to the Swoole socket via WritableConnection. 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.

  2. Symfony StreamedResponse (echo-based): ResponseBridge intercepts echo output via ob_start with a callback wired to $swooleResponse->write(). OB level is safely restored in a try/finally even if sendContent() 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\Server had its own CoreMiddleware (extending the now-deleted HttpServer\CoreMiddleware), its own MiddlewareManager usage, and its own Dispatched result handling.

WebSocketServer\Server::initCoreMiddleware() now calls the same Router::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:

Layer Class Responsibility
Transport HttpServer\Server Swoole adapter. RequestBridge / ResponseBridge conversion, lifecycle events (Telescope), initCoreMiddleware() boot sequence. Implements OnRequestInterface.
Application Foundation\Http\Kernel Laravel-style kernel. Global middleware pipeline, Router::dispatch(), response preparation. No Swoole dependencies. Implements Contracts\Http\Kernel.
App App\Http\Kernel Extends Foundation\Http\Kernel. Defines $middleware, $middlewareGroups, $middlewareAliases, $middlewarePriority. Same structure as Laravel.

The adapter resolves the Kernel via the Contracts\Http\Kernel contract binding (set in bootstrap/app.php), the same pattern Laravel uses. The Kernel takes an Hypervel\Http\Request and returns a Symfony\Component\HttpFoundation\Response. It has no Swoole dependencies.


Breaking Changes & Migration

Middleware Signature

// Before (PSR-15)
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ResponseInterface;

class MyMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        return $handler->handle($request);
    }
}

// After (Laravel-style)
use Closure;
use Hypervel\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class MyMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        return $next($request);
    }
}

Request/Response Types

// Before
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Hypervel\HttpMessage\Server\Request;

// After
use Hypervel\Http\Request;
use Symfony\Component\HttpFoundation\Response;

HTTP Exceptions

// Before
use Hypervel\HttpMessage\Exceptions\NotFoundHttpException;
use Hypervel\HttpMessage\Exceptions\AccessDeniedHttpException;

// After
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

Route Registration

// Before (Hyperf-style)
Route::post('/users', [UserController::class, 'store'], ['middleware' => ['auth']]);
Route::group('/admin', function () { ... }, ['middleware' => ['auth']]);

// After (Laravel-style)
Route::post('/users', [UserController::class, 'store'])->middleware('auth');
Route::middleware('auth')->prefix('/admin')->group(function () { ... });

Config / Kernel Binding

// server.php: ON_REQUEST points to the adapter, not the Kernel
Event::ON_REQUEST => [Hypervel\HttpServer\Server::class, 'onRequest'],

// bootstrap/app.php: add HTTP Kernel contract binding
$app->bind(Hypervel\Contracts\Http\Kernel::class, App\Http\Kernel::class);

Deleted Packages & Classes

  • http-message: entire package (PSR-7 implementation, streams, URI, cookies, HTTP exceptions)
  • dispatcher: HttpDispatcher, Psr15AdapterMiddleware, AdaptedRequestHandler, Pipeline
  • Router: RouteCollector, DispatcherFactory, Dispatched, Handler, RouteFileCollector
  • HttpServer: CoreMiddleware, MiddlewareManager, MiddlewareExclusionManager, PriorityMiddleware, PSR-7 Request/Response wrappers, ResponseEmitter
  • Http: CoreMiddleware, DispatchedRoute, RouteDependency, AcceptHeader, HeaderUtils, Cors
  • Contracts: all PSR-7 Plus interfaces (MessagePlusInterface, RequestPlusInterface, ResponsePlusInterface, ServerRequestPlusInterface, UriPlusInterface), Http\Request, Http\Response
  • 19 PSR-15 middleware implementations rewritten to Laravel's closure-based pattern

Summary

Dimension Before After
Route matching O(n) linear scan, 4 validators per route O(1) hash (static routes) / single regex (dynamic)
Per-request Route construction Every request (reconstructed from attributes) Never, cached at boot and reused
Per-request regex compilation Every request (Route::bind() recompiles) Never, pre-compiled at boot
Per-request Reflection 2+ ReflectionMethod per request Pre-warmed at boot
Per-request middleware resolution Full pipeline: Collection, group expansion, Reflection, priority sort Middleware class lists pre-cached on Route; pipeline construction + container resolution still per-request but cheap (auto-singletoned instances)
Response preparation Called twice per request (inner + outer) Same, matches Laravel's event dispatch pattern
Request object layers 4-5 wrapper/proxy objects 1 object (direct HttpFoundation)
Worker restart warm-up Full re-initialization None, COW fork from master
First-request latency Higher (lazy cache population) Matches subsequent requests for pre-warmed caches
Laravel API parity Partial: custom routing API, PSR-15 middleware, PSR-7 request/response 1:1: same registration, middleware, request/response APIs
Upstream merge difficulty High: heavy divergence from illuminate/* Low: 1:1 ports with isolated Swoole adaptations

New 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:

  • Automatic propagation to queued jobs. Any metadata you add to propagated context during a request - tenant ID, user preferences, feature flags, trace IDs, locale, whatever - automatically serializes into every job payload dispatched during that request and deserializes when the job runs. No manual passing of context through job constructors.
  • Automatic context in log entries. Any metadata in propagated context automatically appears in the extra field of every log entry for that request. No need to pass context arrays to every Log::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 Context and Laravel's Context are fundamentally different things:

Hypervel Context Laravel Context
Purpose Coroutine-local storage for any per-request state Metadata that propagates to logs and jobs
Storage Swoole coroutine context (C-level) Instance properties on a Repository object
Scope Framework internals + developer use Developer-facing only
Propagation No - stays in current coroutine Yes - serialized into job payloads, added to log records

Porting Laravel's Illuminate\Log\Context classes directly would create confusing overlap - two "context" classes doing different things. Instead, I extended Context with a propagated() method that returns a PropagatedContext instance:

  • One canonical entry point (Context) for all per-request state
  • Clear separation - set()/get() for raw coroutine storage, propagated()->add()/get() for metadata that flows to logs/jobs
  • Laravel-compatible API - PropagatedContext methods map directly to Laravel's Repository methods
  • No second competing store - the PropagatedContext instance is stored inside coroutine context under a reserved key

Why propagated() instead of flat static methods

I considered adding static methods like Context::getPropagated(), Context::forgetPropagated(), Context::getPropagatedHidden() etc. directly on Context. Rejected because:

  • The full API surface would add 30+ static methods to Context, most with verbose names (forgetPropagatedHidden, pullPropagatedHidden)
  • Context::propagated() returning the PropagatedContext instance is cleaner - one entry point, then use Laravel-equivalent method names (add, get, forget, addHidden, etc.)
  • The returned instance can be stored in a variable for repeated use: $propagated = Context::propagated();
  • Maps directly to Laravel's Context:: facade methods, making migration documentation straightforward

Architecture

Context::set('key', 'value')              ->  Swoole coroutine context (raw storage)
Context::get('key')                       ->  Swoole coroutine context (raw storage)
Context::propagated()                     ->  PropagatedContext instance (stored in coroutine context)
Context::propagated()->add('key', 'val')  ->  PropagatedContext::$data array
Context::propagated()->addHidden(...)     ->  PropagatedContext::$hidden array
Context::propagated()->dehydrate()        ->  Serializes $data + $hidden for job payload
Context::propagated()->hydrate($payload)  ->  Deserializes into $data + $hidden from job payload

The PropagatedContext instance lives under the coroutine context key __context.propagated, created lazily on first access:

public static function propagated(): PropagatedContext
{
    return self::getOrSet(
        self::PROPAGATED_CONTEXT_KEY,
        fn () => new PropagatedContext(app(Dispatcher::class))
    );
}

Context::hasPropagated() checks if a PropagatedContext instance 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

ContextLogProcessor is pushed onto every resolved log channel. It adds propagated context data to the log record's extra field:

class ContextLogProcessor implements ProcessorInterface
{
    public function __invoke(LogRecord $record): LogRecord
    {
        if (! Context::hasPropagated()) {
            return $record;
        }

        $propagated = Context::propagated()->all();

        if ($propagated === []) {
            return $record;
        }

        return $record->with(extra: [
            ...$record->extra,
            ...$propagated,
        ]);
    }
}

Key details:

  • Only visible data ($data) flows to logs. Hidden data ($hidden) is intentionally excluded from log records - it only propagates to jobs.
  • The hasPropagated() guard means zero overhead on requests that never use propagated context.
  • The processor is pushed in LogManager::get() for named channels and LogManager::stack() for on-demand stacks.
  • createStackDriver() filters out ContextLogProcessor from constituent channel processors to prevent duplication when channels are combined into stacks.

How it works: queue integration

Wired in QueueServiceProvider::boot():

// When creating job payloads, dehydrate propagated context into the payload.
Queue::createPayloadUsing(function (string $connection, ?string $queue, array $payload): array {
    if (! Context::hasPropagated()) {
        return [];
    }

    $context = Context::propagated()->dehydrate();

    return $context === null ? [] : ['hypervel:context' => $context];
});

// When processing jobs, hydrate propagated context from the payload.
$this->app['events']->listen(JobProcessing::class, function (JobProcessing $event): void {
    $context = $event->job->payload()['hypervel:context'] ?? null;

    if ($context === null) {
        return;
    }

    Context::propagated()->hydrate($context);
});

Key details:

  • dehydrate() works on a clone so dehydrating callbacks can modify values without affecting the live propagated context.
  • dehydrate() returns null when propagated context is empty - no payload bloat for jobs that don't use context.
  • hydrate() flushes existing propagated context before restoring, dispatches ContextHydrated so service providers can react.
  • No explicit cleanup on JobProcessed/JobExceptionOccurred is 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).
  • ContextDehydrating and ContextHydrated events allow service providers to hook into the serialization lifecycle.

How it works: coroutine isolation

When coroutines are forked via Context::copy(), the PropagatedContext instance 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:

// In Context::copy()
if (isset($map[self::PROPAGATED_CONTEXT_KEY])
    && $map[self::PROPAGATED_CONTEXT_KEY] instanceof PropagatedContext
) {
    $map[self::PROPAGATED_CONTEXT_KEY] = $map[self::PROPAGATED_CONTEXT_KEY]->replicate();
}

The same replication is applied in Context::copyFromNonCoroutine().

Usage examples

// Add context at the start of a request (e.g. in middleware)
Context::propagated()->add('trace_id', $request->header('X-Trace-Id'));
Context::propagated()->add('user_id', auth()->id());

// Hidden context flows to jobs but NOT to logs (sensitive data)
Context::propagated()->addHidden('api_key', $apiKey);

// Every log entry now automatically includes trace_id and user_id
Log::info('Processing order', ['order_id' => $order->id]);
// Log extra: {"trace_id": "abc-123", "user_id": 42}

// Every dispatched job automatically carries the context
ProcessOrder::dispatch($order);
// Job payload includes: {"hypervel:context": {"data": {...}, "hidden": {...}}}

// Inside the job, context is automatically restored
class ProcessOrder implements ShouldQueue
{
    public function handle(): void
    {
        $traceId = Context::propagated()->get('trace_id'); // "abc-123"
        $apiKey = Context::propagated()->getHidden('api_key'); // restored
    }
}

// Temporary context for a specific scope
Context::propagated()->scope(function () {
    // Additional context only exists inside this callback
}, data: ['scope' => 'payment'], hidden: ['token' => $paymentToken]);

// Hooks for serialization lifecycle
Context::propagated()->dehydrating(function (PropagatedContext $context) {
    $context->add('dehydrated_at', now()->toISOString());
});

Context::propagated()->hydrated(function (PropagatedContext $context) {
    // React to restored context in the job worker
});

Laravel equivalence mapping

Full API parity with Laravel's Illuminate\Log\Context\Repository:

Laravel Context:: Hypervel
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

File Description
src/context/src/PropagatedContext.php Core class - data + hidden storage, serialization, hooks, scoping
src/context/src/Events/ContextDehydrating.php Event dispatched before context is serialized for a job payload
src/context/src/Events/ContextHydrated.php Event dispatched after context is restored from a job payload
src/log/src/ContextLogProcessor.php Monolog processor that adds propagated context to log record extras

Modified files

File Change
src/context/src/Context.php Added propagated(), hasPropagated(), PROPAGATED_CONTEXT_KEY constant, replicate() calls in copy() and copyFromNonCoroutine()
src/context/src/helpers.php Updated context() docblock to reference Context::propagated()
src/context/composer.json Added dependencies: hypervel/events, hypervel/queue, hypervel/conditionable, hypervel/macroable, hypervel/support
src/contracts/src/Queue/Job.php Added payload() method to the Job contract
src/log/src/LogManager.php Pushes ContextLogProcessor onto resolved channels and on-demand stacks, filters duplicates in stack driver
src/queue/src/QueueServiceProvider.php Wires Queue::createPayloadUsing for dehydration and JobProcessing listener for hydration

Test coverage

Test file Tests What it covers
tests/Context/PropagatedContextTest.php 57 tests Unit tests for all PropagatedContext methods - data ops, hidden ops, stacks, counters, scope, flush, dehydrate/hydrate, event hooks, error handling
tests/Context/ContextPropagatedTest.php 11 tests Integration of Context::propagated() - lazy creation, same-instance identity, isolation from raw context, hasPropagated() guard, flush() clearing
tests/Context/PropagatedContextCoroutineTest.php 4 tests Coroutine isolation - separate instances per coroutine, non-inheritance by children, fork copy isolation (regression test for shallow copy bug), cross-coroutine dehydrate/hydrate
tests/Context/PropagatedContextLogTest.php 6 tests Log integration - context appears in log extras, hidden data excluded, no override of message context, processor skip when no context exists, custom processor binding
tests/Context/PropagatedContextQueueTest.php 12 tests Queue integration - payload inclusion, empty context skip, hidden data in payload, hydration on job processing, dehydrating/hydrated hooks, round-trip type preservation, end-to-end sync job
tests/Integration/Context/PropagatedContextIntegrationTest.php 7 tests End-to-end integration through the real LogManager - propagated data in channel logs, stack logs, hidden exclusion, empty context skip, processor skip

2. Method name alignment: destroy -> forget, destroyAll -> flush

Renamed Context::destroy() to Context::forget() and Context::destroyAll() to Context::flush(). Aligns with Laravel's naming conventions (forget used throughout Collections, Session, Cache, etc.) and with PropagatedContext which also uses forget() and flush().

Hard break, no deprecation - pre-1.0 and all call sites are ours. ~50 files across src/ and tests/.

The same rename was applied to the context subclasses: ParentContext::forget(), RequestContext::forget(), ResponseContext::forget(), and WebSocketServer\Context::forget().

@binaryfire binaryfire marked this pull request as draft January 26, 2026 03:51
@binaryfire
Copy link
Copy Markdown
Collaborator Author

binaryfire commented Jan 26, 2026

@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:

Once the Laravel database tests are ported to Hypervel, incorporating Laravel PRs becomes straightforward:

  1. Fetch the PR diff with `gh pr diff <number> --repo laravel/framework`
  2. Apply the same changes to the equivalent Hypervel files (usually just namespace changes from `Illuminate` → `Hypervel`)
  3. Run the ported tests to verify the behavior matches

  The main work is already done - the logic is identical, just namespaced differently.

  The PR I just looked at would have taken ~5 minutes to apply:
  
  - Add `selectExpression()` method
  - Add two `elseif` checks in `select()` and `addSelect()`
  - Add a type assertion in the types file
  - Run existing tests

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.

@albertcht albertcht added the breaking-change Breaking changes label Jan 27, 2026
@albertcht
Copy link
Copy Markdown
Member

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:

  • Scope of the v0.4 Refactor: We should clearly define the expected boundaries for the v0.4 branch. Is the plan to remove all Hyperf package dependencies within v0.4? I personally lean towards this approach—if we are refactoring, converting all Hyperf packages to native Hypervel packages would be the cleaner and more sustainable choice in the long run.

  • Database Stability Testing: The database packages are critical and logically complex components of the framework. Before the official v0.4 release, we need to ensure we allocate sufficient time for extensive testing on this part—including the transactions you mentioned and various edge cases—to guarantee data correctness and safety.

  • Documentation of Breaking Changes: Obviously, v0.4 will contain a significant number of breaking changes. Throughout the development process, we need to fully document these changes as we go so we can provide a detailed Upgrade Guide upon release to lower the migration barrier for users.

  • ConfigProvider Support: Just to confirm—based on your description, we will not be removing support for ConfigProvider in v0.4 yet, correct? (Keeping it during this transition phase seems reasonable until the Container and Config systems are fully refactored).

  • Core Component Performance: Regarding future refactors for components like Container, Events, Routes, and HTTP-Server—beyond the primary goal of coroutine safety, I also want to emphasize the importance of benchmark testing. These are the core components of Hypervel and are critical for performance. I hope that while modernizing the architecture, the new refactors will not lead to any performance regression.

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.

@binaryfire
Copy link
Copy Markdown
Collaborator Author

binaryfire commented Jan 28, 2026

Hi @albertcht

Thanks for the detailed response! I'm glad we're aligned on the direction. Let me address each point:

  • Scope of v0.4: I agree - let's do a complete removal of all Hyperf dependencies in v0.4. If we're refactoring, doing it all at once is cleaner than maintaining two architectures in parallel. I'm working on this full-time so I can move quickly - I estimate 3-4 months for the complete port including Container, Config, Console, Router, HTTP Server, everything.

  • ConfigProvider: Actually, I now plan to remove ConfigProvider entirely in v0.4 as part of the complete port. Since we're going all-in on Laravel parity, moving fully to Service Providers makes sense now rather than later.

  • Database Stability: Completely agree - stability and code quality are my top priorities. This is one of the reasons I made that PR to increase PHPStan to level 5. For testing, I'm porting the full Laravel test suite for each package to tests/{PackageName}/Laravel. This gives us all of Laravel's test coverage, and by keeping them in a separate directory instead of mixing them with our own tests, it's easy to see what's missing when comparing against Laravel's test directory. I'm working through the database tests now.

  • Performance: Don't worry - I'm a bit obsessed with performance and optimisation 😄 Whenever I port a Laravel package, I always review Hyperf's implementation first to incorporate any performance optimisations they've made, as well as looking for new opportunities in the ported code (like the static caching I added to the database package). And yes, we should definitely run benchmarks before release to catch anything that may have been missed.

  • Breaking Changes: Since the end state is 1:1 Laravel API parity, I think the upgrade guide becomes simpler than documenting every individual change. It's essentially: "Hypervel now matches Laravel's API exactly, with these Swoole-specific exceptions." Documenting changes as I go would slow things down significantly given how massive the scope of this refactor is. I suggest we figure out the best way to communicate the breaking changes after the refactor is complete, once we have a clear picture of exactly what the exceptions are.

  • Review Process: Rather than leaving the review until the end, I'll ping you when each package is fully complete (src + tests) so you can review it. That way our work is progressive and spread out rather than one massive review at the end.

Let me know your thoughts!

@binaryfire binaryfire force-pushed the feature/hyperf-decouple branch from 8cec3bf to bfffa6f Compare January 30, 2026 06:48
@binaryfire
Copy link
Copy Markdown
Collaborator Author

binaryfire commented Feb 2, 2026

Hi @albertcht. The hypervel/database package is ready for review. Could you take a look when you have time?

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:

  • The ConfigProvider, Hyperf-style listeners and Hyperf-based commands are still there for now. I'll remove those once the necessary dependencies are ported.
  • Given that our goal is 1:1 parity with Laravel, several things have changed. For example, model events are no longer class-based. They are the same string-based events that Laravel uses. I'll be updating the events package to be 1:1 with Laravel too.

Comment thread src/database/src/Capsule/Manager.php
Comment thread src/database/src/Console/Migrations/FreshCommand.php
Comment thread src/database/src/Console/Migrations/RefreshCommand.php
Comment thread src/database/src/Console/Migrations/ResetCommand.php
Comment thread src/database/src/Console/Migrations/RollbackCommand.php
Comment thread src/database/src/Console/SeedCommand.php Outdated
@binaryfire binaryfire force-pushed the feature/hyperf-decouple branch from 80f3ef2 to 94c115e Compare February 6, 2026 07:25
@binaryfire
Copy link
Copy Markdown
Collaborator Author

binaryfire commented Feb 8, 2026

@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.

  • hypervel/context
  • hypervel/coordinator
  • hypervel/coroutine
  • hypervel/engine
  • hypervel/pool
  • hypervel/redis

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 subscribe() in go() it wouldn't block the calling coroutine. But yhe problem is what happens inside that coroutine. phpredis's subscribe() blocks the coroutine it's called in for the entire subscription lifetime, which means:

  1. Pool connection exhaustion: Each subscriber holds a pool connection that's never released until the subscription ends. With the default pool size, a few long-lived subscribers can starve other Redis operations.

  2. Shutdown deadlock: As mentioned in [FEATURE] Use https://openswoole.com/article/redis-swoole-pubsub for redis connection hyperf/hyperf#4775, when the worker exits, the subscribe coroutine is stuck on recv() with no way to interrupt it from PHP, so the worker hangs. The socket-based subscriber makes this solvable — because we control the socket, we can close it from another coroutine to break the recv loop. I added a shutdown watcher (CoordinatorManager::until(WORKER_EXIT)) to do exactly that, giving us clean worker exit.

  3. No dynamic channel management: phpredis's callback API doesn't support subscribing/unsubscribing after the initial call. The new Subscriber class supports subscribe(), unsubscribe(), psubscribe(), punsubscribe() as separate operations on the same connection.

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 RESP protocol, coroutine-based recv loop, and Channel for message delivery. It's the same way go-redis handles this.

This is a good article re: this issue for reference: https://openswoole.com/article/redis-swoole-pubsub

@binaryfire binaryfire mentioned this pull request Feb 9, 2026
@binaryfire
Copy link
Copy Markdown
Collaborator Author

binaryfire commented Feb 12, 2026

Hi @albertcht! The new hypervel/container package is ready for review.

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(), and define(). That's the entire public API. It has no support for:

  • Contextual bindings (when()->needs()->give())
  • Resolving callbacks (resolving(), afterResolving(), beforeResolving())
  • Service extenders (extend())
  • Tagging (tag(), tagged())
  • Scoped bindings (scoped()) for request-lifecycle singletons
  • Method bindings (bindMethod(), call() with dependency injection)
  • Rebound callbacks (rebinding())
  • Binding-time singleton/transient control (bind() vs singleton())
  • Attribute-based contextual injection (#[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:

Method Description
bind($abstract, $concrete, $shared) Register a binding (fresh instance each resolution)
bindIf(...) Register only if not already bound
singleton($abstract, $concrete) Register a shared binding (cached for worker lifetime)
singletonIf(...) Register only if not already bound
scoped($abstract, $concrete) Register a request-scoped singleton (cached in coroutine-local Context)
scopedIf(...) Register only if not already bound
instance($abstract, $instance) Register a pre-resolved instance
make($abstract, $parameters) Resolve from the container (respects binding type)
get($id) PSR-11 compliant resolution
build($concrete) Always build a fresh instance, bypassing bindings
call($callback, $parameters) Call a method/closure with dependency injection
when($concrete)->needs($abstract)->give($impl) Contextual bindings
extend($abstract, $closure) Decorate or modify resolved instances
tag($abstracts, $tags) / tagged($tag) Group bindings by tag
resolving() / afterResolving() / beforeResolving() Resolution lifecycle hooks
afterResolvingAttribute($attribute, $callback) Attribute-based lifecycle hooks
whenHasAttribute($attribute, $handler) Attribute-based contextual injection
alias($abstract, $alias) Type aliasing
rebinding($abstract, $callback) Rebound event hooks
factory($abstract) Get a closure that resolves the type
wrap($callback, $parameters) Wrap a closure for deferred dependency injection
flush() Clear all bindings, instances, and caches
forgetInstance($abstract) Remove a single cached instance
forgetInstances() Remove all cached instances
forgetScopedInstances() Remove all scoped instances (request cleanup)

It also supports closure return-type bindings (register a binding by returning a typed value from a closure, including union types), SelfBuilding interface for classes that control their own instantiation, and ArrayAccess for $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 $autoSingletons array (not $instances) so that bound() 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:

Attribute Resolves
#[Auth] Auth guard instance
#[Authenticated] Currently authenticated user (or throws)
#[Cache] Cache store instance
#[Config('key')] Configuration value
#[Context('key')] Coroutine context value
#[CurrentUser] Current user (nullable)
#[Database] Database connection
#[DB] Database connection (alias)
#[Give(value)] Inline contextual value
#[Log] Logger channel
#[RouteParameter('name')] Route parameter value
#[Scoped] Mark class as request-scoped singleton
#[Singleton] Mark class as process-global singleton
#[Storage] Filesystem disk instance
#[Tag('name')] Resolve all tagged bindings
#[Bind(Concrete::class)] Attribute-based interface binding (with environment support)

Example:

class OrderService
{
    public function __construct(
        #[Config('orders.tax_rate')] private float $taxRate,
        #[Tag('payment-processors')] private array $processors,
        #[Authenticated] private User $user,
    ) {}
}

Performance

Build recipe caching

Constructor parameters are analyzed via reflection once per class and cached as BuildRecipe / ParameterRecipe value objects for the worker lifetime. Subsequent resolutions read from cached metadata with zero reflection overhead.

First resolution:  ReflectionClass → ReflectionMethod → ReflectionParameter[] → BuildRecipe (cached)
Subsequent:        BuildRecipe → resolve parameters → instantiate (no reflection)

Method parameter caching

Container::call() caches method parameter metadata the same way - BoundMethod maintains a static $methodRecipes cache keyed by ClassName::methodName. Closures and global function strings fall back to per-call reflection since they lack a deterministic cache key.

Reflection caching

ReflectionManager caches ReflectionClass, ReflectionMethod, and ReflectionProperty objects statically for the worker lifetime, shared across the container and the rest of the framework.

Hot-path optimizations

  • Singleton cache hit path: alias resolution → empty-check early exits for callbacks → isset() on $instances → return. No Context reads on the singleton path when no contextual bindings are registered.
  • $scopedInstances is a keyed array for O(1) isScoped() lookups (not in_array()).
  • $extenders and resolving callback arrays are guarded by empty() checks before iteration.
  • $this->contextual is 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 because BuildRecipe caching eliminates repeated reflection - Hyperf's ParameterResolver re-processes ReflectionParameter objects on every make() 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:

State Storage Lifetime
Build stack Context Transient (during resolution, cleaned in finally)
Resolving stack Context Transient (during resolution, cleaned in finally)
Parameter overrides Context Transient (during resolution, cleaned in finally)
Resolution depth Context Transient (during resolution, cleaned in finally)
Scoped instances Context Request lifetime (cleaned on coroutine exit)
Singletons $instances Worker lifetime (process-global, by design)
Auto-singletons $autoSingletons Worker lifetime (process-global, by design)
Bindings, aliases, tags, etc. Properties Worker lifetime (process-global, by design)

Circular dependency detection uses two complementary mechanisms:

  1. Resolving stack - direct cycle detection with full dependency chain reporting (e.g., A → B → C → A), stored in a dedicated Context key separate from the build stack to avoid false positives from call()
  2. Depth guard - safety net for indirect cycles through aliases/interfaces where abstract names differ, capped at 500 levels

All transient Context state is cleaned up in finally blocks, 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(), and flush() 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:

  • ContainerTest - core API (binding, resolution, singleton, scoped, aliases, flush, attributes)
  • ContainerCallTest - call() method, dependency injection, method recipe caching
  • ContextualBindingTest - when()->needs()->give(), variadic bindings, contextual primitives
  • ContainerExtendTest - service extenders, scoped extend behavior
  • ContainerTaggingTest - tagging and tagged resolution
  • ResolvingCallbackTest - before/resolving/after callbacks, global and typed
  • AfterResolvingAttributeCallbackTest - attribute-based lifecycle callbacks
  • ContainerResolveNonInstantiableTest - error messages for interfaces, abstract classes
  • CoroutineSafetyTest - coroutine isolation for scoped instances, build stack, parameter overrides, exception cleanup
  • ReflectionManagerTest - reflection caching
  • RewindableGeneratorTest - lazy tagged resolution
  • UtilTest - utility helpers

Everything passes at PHPStan level 5.

Let me know what you think

Comment thread src/database/src/Console/Migrations/MigrateCommand.php Outdated
Comment thread src/database/src/Eloquent/Concerns/HasEvents.php Outdated
Comment thread src/database/src/Console/WipeCommand.php
Comment thread src/database/src/Eloquent/Concerns/HasAttributes.php Outdated
Comment thread src/database/src/Eloquent/Events/Booted.php Outdated
Comment thread src/database/src/Eloquent/Factories/Factory.php
Comment thread src/database/src/Eloquent/Relations/Relation.php Outdated
Comment thread src/database/src/Eloquent/BroadcastsEventsAfterCommit.php Outdated
Comment thread src/database/src/Eloquent/Collection.php Outdated
Comment thread src/database/src/Listeners/RegisterConnectionResolverListener.php Outdated
Comment thread src/database/src/Pool/PooledConnection.php Outdated
Comment thread src/database/src/Pool/PooledConnection.php Outdated
Comment thread src/database/src/Pool/PooledConnection.php Outdated
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.
@binaryfire binaryfire marked this pull request as ready for review April 3, 2026 01:47
@binaryfire binaryfire merged commit c819da3 into hypervel:0.4 Apr 3, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change Breaking changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants