Skip to content

Laravel: Disabling Pagination Causes 403 on Policy-Protected Collection Endpoints #8482

Description

@PicassoHouessou

API Platform version(s) affected: 4.3.17 (api-platform/laravel)

Description

Any GetCollection operation that has a Policy/security expression (resolved via a Laravel Policy's viewAny() method, e.g. through the standard Gate::getPolicyFor($model) naming-convention resolution) unconditionally returns 403 Access Denied when the request disables client-side pagination (?pagination=false or ?pagination=0), regardless of authentication state or the policy's actual logic — even when viewAny() unconditionally returns true.

Root cause: ApiPlatform\Laravel\Security\ResourceAccessChecker::isGranted() special-cases the Paginator wrapper class when deciding what object to pass to Gate::allows():

public function isGranted(string $resourceClass, string $expression, array $extraVariables = []): bool
{
    $object = $extraVariables['object'] ?? null;

    return Gate::allows(
        $expression,
        ($object instanceof Paginator || null === $object) ? $resourceClass : $object
    );
}

When pagination is enabled, ApiPlatform\Laravel\Eloquent\State\CollectionProvider::provide() returns an ApiPlatform\Laravel\Eloquent\Paginator instance, so $object instanceof Paginator is true, and Gate::allows($expression, $resourceClass) is called with the resource class string — which correctly resolves to the model's Policy class and calls viewAny().

When pagination is disabled, the same provider instead returns a plain Illuminate\Database\Eloquent\Collection (see CollectionProvider::provide(), the false === $this->pagination->isEnabled(...) branch: return $query->get();). This is not a Paginator, so ResourceAccessChecker passes the Collection object itself to Gate::allows($expression, $collection). Laravel's Gate then tries to resolve a policy based on get_class($collection) (Illuminate\Database\Eloquent\Collection), finds no policy registered for that class, and — absent any other applicable ability/Gate::before() override — denies the request by default.

The result: disabling pagination on any policy-protected collection endpoint always 403s, independent of the actual authorization rule.

How to reproduce

Any Eloquent resource with a Policy providing viewAny():

class PaymentMethodPolicy
{
    public function viewAny(?RemoteUser $user): bool
    {
        return true; // unconditionally public
    }
}
GET /api/payment_methods
→ 200 OK (Gate::allows('viewAny', PaymentMethod::class) — resolves the policy correctly)

GET /api/payment_methods?pagination=false
→ 403 {"detail":"Access Denied."}
(reproduces identically authenticated or unauthenticated)

GET /api/payment_methods?pagination=0
→ 403 (same)

Possible Solution

ResourceAccessChecker::isGranted() should pass the resource class string whenever $object is a bare collection of resources (i.e., not a single instance of the resource itself) rather than only special-casing Paginator. For example, also treat PartialPaginator and plain Illuminate\Support\Collection/Illuminate\Database\Eloquent\Collection instances the same way Paginator is treated:

$isCollectionLike = $object instanceof Paginator
    || $object instanceof PartialPaginator
    || $object instanceof \Illuminate\Support\Collection;

return Gate::allows(
    $expression,
    ($isCollectionLike || null === $object) ? $resourceClass : $object
);

Additional Context

  • CollectionProvider: vendor/api-platform/laravel/Eloquent/State/CollectionProvider.php
  • ResourceAccessChecker: vendor/api-platform/laravel/Security/ResourceAccessChecker.php
  • AccessCheckerProvider (throw site): vendor/api-platform/laravel/State/AccessCheckerProvider.php
  • This was found while debugging a checkout page that couldn't load any payment methods; it was initially conflated with an unrelated BooleanFilter validation bug (see docs/bug-reports/api-platform-boolean-filter-validation.md) because both bugs happened to be triggered by parameters on the same request (?enabled=true&pagination=false) and both produce failure responses that look superficially similar during triage (422 vs 403) unless isolated parameter-by-parameter.
  • Workaround applied in our app: stopped disabling client-side pagination on this endpoint (removed pagination: false from the frontend query) — the resource's default page size (20) is more than sufficient for a payment-methods list, so no functional loss.

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