Summary
The three middlewares in src/permission/src/Middleware/ declare a handle() return type of
Hypervel\Http\Response. Under declare(strict_types=1) that accepts only a
Hypervel\Http\Response or a subclass — and Hypervel\Http\JsonResponse is not one. It
extends Symfony\Component\HttpFoundation\JsonResponse, making it a sibling of
Hypervel\Http\Response, not a subtype.
handle() ends in return $next($request);, so whatever the downstream pipeline produces has
to satisfy that narrowed type. Since ResponseFactory::json() is declared
: JsonResponse, every controller that returns response()->json(...) produces exactly the
type the middleware rejects. The result is a fatal TypeError on the success path.
In practice this makes the permission package's own middleware unusable on a JSON API — i.e.
on the majority of routes anyone would want to gate by permission.
Environment
|
|
hypervel/components |
0.4.x-dev (b840fb5) |
| PHP |
8.4 (phpswoole/swoole:6.2.2-php8.4-alpine) |
| Affected files |
src/permission/src/Middleware/PermissionMiddleware.php, RoleMiddleware.php, RoleOrPermissionMiddleware.php |
Steps to reproduce
-
A controller action returning JSON:
public function index()
{
return response()->json(['data' => []]); // Hypervel\Http\JsonResponse
}
-
Gate the route with the package's middleware:
Route::middleware(['auth:sanctum', 'permission:roles.index'])
->get('/api/roles', [RoleController::class, 'index']);
-
Request it as a user who passes the permission check.
Expected: 200 with the JSON body.
Actual: 500 —
TypeError: Hypervel\Permission\Middleware\PermissionMiddleware::handle():
Return value must be of type Hypervel\Http\Response, Hypervel\Http\JsonResponse returned
Note the failure only shows up on the pass path. A denied request throws
UnauthorizedException before reaching the return, so the narrowed type is never exercised —
which is why a permissions test suite that only asserts 403s will not catch this.
Root cause
src/permission/src/Middleware/PermissionMiddleware.php:
use Hypervel\Http\Response; // line 11
public function handle(Request $request, Closure $next, mixed $permission, ?string $guard = null): Response // line 31
{
// ...
foreach ($permissions as $permissionName) {
if ($user->can($permissionName)) {
return $next($request); // may be a JsonResponse → TypeError
}
}
throw UnauthorizedException::forPermissions($permissions);
}
The relevant hierarchy:
Symfony\Component\HttpFoundation\Response
├── Hypervel\Http\Response ← the middleware's declared return type
└── Symfony\Component\HttpFoundation\JsonResponse
└── Hypervel\Http\JsonResponse ← what controllers return
RoleMiddleware (line 10 / line 30) and RoleOrPermissionMiddleware (line 11 / line 31)
carry the identical import and return type.
This looks like an oversight, not a design choice
Every other middleware shipped in hypervel/components already types against Symfony's
Response. Of the 27 handle() methods under src/*/src/Middleware/ that declare a response
return type:
| Return type |
Count |
Examples |
Symfony\Component\HttpFoundation\Response |
24 |
Authenticate, Authorize, EnsureEmailIsVerified, SubstituteBindings, ThrottleRequests, StartSession, HandleCors, EncryptCookies, … |
Hypervel\Http\Response |
3 |
the three permission middlewares |
(A further 10 handle() methods — mostly queue middleware such as RateLimited and
WithoutOverlapping, plus PrefersJsonResponses and AuthenticateSession — return mixed
and aren't comparable.)
src/http/src/Middleware/AddLinkHeadersForPreloadedAssets.php is instructive: it imports
Hypervel\Http\Response for internal use and imports
Symfony\Component\HttpFoundation\Response as SymfonyResponse specifically to type handle()
against the broad one. The permission package appears to have simply missed that distinction.
Suggested fix
Widen the return type to Symfony's Response in all three files:
-use Hypervel\Http\Response;
+use Symfony\Component\HttpFoundation\Response;
public function handle(Request $request, Closure $next, mixed $permission, ?string $guard = null): Response
The signature text is unchanged; only the import moves. This matches the other 22 middlewares
and is a widening, so it cannot break an existing caller.
A regression test would want to assert the pass path returns a JsonResponse intact,
since the current suite's 403 assertions can't reach the failing return.
Workaround for anyone hitting this
Don't route through the vendor middleware — use a thin local one performing the same check but
typed against the broad response:
final class RequirePermission
{
public function handle(Request $request, Closure $next, string $permission): SymfonyResponse
{
$user = $request->user();
if ($user === null) {
throw UnauthorizedException::notLoggedIn();
}
if (! $user->can($permission)) {
throw UnauthorizedException::forPermissions([$permission]);
}
return $next($request);
}
}
Happy to open a PR for the three-line import change if that's useful.
Summary
The three middlewares in
src/permission/src/Middleware/declare ahandle()return type ofHypervel\Http\Response. Underdeclare(strict_types=1)that accepts only aHypervel\Http\Responseor a subclass — andHypervel\Http\JsonResponseis not one. Itextends
Symfony\Component\HttpFoundation\JsonResponse, making it a sibling ofHypervel\Http\Response, not a subtype.handle()ends inreturn $next($request);, so whatever the downstream pipeline produces hasto satisfy that narrowed type. Since
ResponseFactory::json()is declared: JsonResponse, every controller that returnsresponse()->json(...)produces exactly thetype the middleware rejects. The result is a fatal
TypeErroron the success path.In practice this makes the permission package's own middleware unusable on a JSON API — i.e.
on the majority of routes anyone would want to gate by permission.
Environment
hypervel/components0.4.x-dev(b840fb5)phpswoole/swoole:6.2.2-php8.4-alpine)src/permission/src/Middleware/PermissionMiddleware.php,RoleMiddleware.php,RoleOrPermissionMiddleware.phpSteps to reproduce
A controller action returning JSON:
Gate the route with the package's middleware:
Request it as a user who passes the permission check.
Expected:
200with the JSON body.Actual:
500—Note the failure only shows up on the pass path. A denied request throws
UnauthorizedExceptionbefore reaching thereturn, so the narrowed type is never exercised —which is why a permissions test suite that only asserts 403s will not catch this.
Root cause
src/permission/src/Middleware/PermissionMiddleware.php:The relevant hierarchy:
RoleMiddleware(line 10 / line 30) andRoleOrPermissionMiddleware(line 11 / line 31)carry the identical import and return type.
This looks like an oversight, not a design choice
Every other middleware shipped in
hypervel/componentsalready types against Symfony'sResponse. Of the 27handle()methods undersrc/*/src/Middleware/that declare a responsereturn type:
Symfony\Component\HttpFoundation\ResponseAuthenticate,Authorize,EnsureEmailIsVerified,SubstituteBindings,ThrottleRequests,StartSession,HandleCors,EncryptCookies, …Hypervel\Http\Response(A further 10
handle()methods — mostly queue middleware such asRateLimitedandWithoutOverlapping, plusPrefersJsonResponsesandAuthenticateSession— returnmixedand aren't comparable.)
src/http/src/Middleware/AddLinkHeadersForPreloadedAssets.phpis instructive: it importsHypervel\Http\Responsefor internal use and importsSymfony\Component\HttpFoundation\Response as SymfonyResponsespecifically to typehandle()against the broad one. The permission package appears to have simply missed that distinction.
Suggested fix
Widen the return type to Symfony's
Responsein all three files:The signature text is unchanged; only the import moves. This matches the other 22 middlewares
and is a widening, so it cannot break an existing caller.
A regression test would want to assert the pass path returns a
JsonResponseintact,since the current suite's 403 assertions can't reach the failing return.
Workaround for anyone hitting this
Don't route through the vendor middleware — use a thin local one performing the same check but
typed against the broad response:
Happy to open a PR for the three-line import change if that's useful.