Skip to content

PermissionMiddleware / RoleMiddleware / RoleOrPermissionMiddleware narrow handle() to Hypervel\Http\Response, causing a TypeError on any JSON route #481

Description

@dangerdd1

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

  1. A controller action returning JSON:

    public function index()
    {
        return response()->json(['data' => []]);   // Hypervel\Http\JsonResponse
    }
  2. Gate the route with the package's middleware:

    Route::middleware(['auth:sanctum', 'permission:roles.index'])
        ->get('/api/roles', [RoleController::class, 'index']);
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions